feat/tunnel-dialer #5
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/tunnel-dialer"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
SPA pings `{action:"authcookie"}` every 30 minutes (`default.handlebars:2268, authCookieRenewTimer`) and expects a `{action:"authcookie", cookie, rcookie}` reply so it can swap in fresh tokens for the relay-tunnel `?auth=` / `?rauth=` query strings and the `devicefile.ashx?c=` direct-download URL. Without this the 24h initial cookies expire mid-session and Desktop / Terminal / Files tunnels stop authenticating. ControlIn::AuthCookie + ControlOut::AuthCookie + dispatch arm that calls `cookie_auth::mint_cookie` for both tags (LOGIN, RELAY) with a 24h TTL matching the initial render-time mint. Two new unit tests cover the wire shape (input parse + reply serialize). Verified end-to-end via Chrome direct send: server returns valid replacement cookies that swap into the SPA's authCookie / authRelayCookie globals. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`name` is the user-editable display label; `rname` is the real hostname the agent reports each connect. The reconnect branch used `entry("name").or_insert(...)` which preserves the original name forever. Result: a Docker dev agent that first registered as `4191faf399dc` keeps that label even after the same identity files get reused by a bare-metal run that reports `desktop-02` as its hostname. Detect "user has customised the display name" by comparing the stored `name` against the stored `rname`. If they match, the user hasn't renamed -> sync `name` to the new hostname. If they differ, keep the user's customisation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Seven SPA-driven actions were silently dropped by the server. Wire each one with the smallest correct shape so the UI stops hanging. `lastconnect` / `lastconnects`: agent.rs now stamps `lastconnect` (unix-ms) + `lastaddr` (peer IP) on the node row at every reconnect (both on the existing-row update branch and the fresh-row create branch). New `LastConnect{nodeid}` and `LastConnects` (no-arg) control messages return the cached values, gated by mesh visibility (`user_can_see_mesh`). `agentdisconnect`: AgentRegistry gains a per-entry `tokio::sync::Notify` plus `disconnect_notify(nodeid) -> Arc<Notify>` and `disconnect(nodeid) -> bool`. agent.rs's select loop now races the notify against ws + outbound recv; on fire, sends a Close frame and breaks the loop. The agent reconnects per its retry policy. Soft (1) and hard (2) modes collapse to the same kick today; documented as informational. The control.ashx handler gates on AGENT_CONSOLE rights. `getsysinfo` / `getnetworkinfo`: stub `noinfo: true` so the SPA's `case 'getsysinfo'` falls through to the empty-info branch instead of waiting forever. Real cache + agent forward lands when the sysinfo cache stash hooks into agent text-frame routing. `powertimeline`: empty timeline reply. `updateAgents`: stub Ack `not-implemented` until the agent-binary self-update push wires through. Tests: parse + wire-shape coverage for the new ControlIn / ControlOut pairs (lastconnect / lastconnects / agentdisconnect). All 330 meshcentral-web lib tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The SPA emits 24 control-channel actions the server was silently dropping; ack each `not-implemented` so the dialogs that wait on a response stop hanging. As real handlers land, swap the catch-all dispatch arm for an explicit per-variant arm. Stubbed (each gets `{action:"ack", for:"stub", result:"not-implemented"}`): user mgmt adddeviceuser, adduserbatch, addusertousergroup, removeuserfromusergroup, emailuser, msguser, smsuser, updateUserImage account changeemail, verifyemail, confirmMessaging, removeMessaging, verifyMessaging, confirmPhone, removePhone, verifyPhone, otpduo, otpemail server-admin serverconfig, serverconsole, servererrors, serverclearerrorlog, serverupdate, servertimelinestats, report, intersession Real handler: changelang persists the caller's preferred language code on their user doc (max 16 chars; empty/None clears the field). Returns "ok"/"db-error". The SPA reads it back via `serverinfo` on the next connect. 330 server lib tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Earlier commit stubbed getsysinfo/getnetworkinfo with `noinfo:true` because the server had no sysinfo cache. Add the cache on the text-frame router side and serve it from the two control actions. `AgentMetadata` gains `cached_sysinfo` + `cached_netinfo` (serde_json::Value), plus matching `AgentMetadataUpdate` builders. `agent::dispatch_text` watches for `msg/sysinfo` replies and stashes `{hardware, time}` on the per-node entry. `msg/networkinfo` (if the agent ever ships one) lands in `cached_netinfo`. Pure side-effect: the existing user-session forward path is unchanged. `user_session::ControlIn::GetSysInfo` / `GetNetworkInfo` now: - cache hit -> respond with the cached hardware/time block (`noinfo` field omitted) - cache miss -> poke the agent with `msg_to_agent("sysinfo", ...)` so the next reply lands in cache, AND respond with `noinfo:true` for this round so the SPA's renderer flips into the empty-info branch instead of hanging `getnetworkinfo` lifts the `network` array out of the cached sysinfo hardware object (the agent doesn't ship a separate msg/networkinfo handler). 330 server lib tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Swap the bulk-stubbed Ack for ControlIn::ServerConfig / ServerConsole / ServerErrors with site-admin-gated implementations: - serverconfig: read config.json from a couple of conventional paths and reply with `{ data: <text> | null }`. The SPA's "no config file" branch covers the null case until AppState pins the resolved path. - serverconsole: tiny built-in admin REPL (help/info/version/time/users/nodes/sessions/agents) returning a `<pre>`-rendered text blob. No shell exec; the legacy command vocabulary lands incrementally. - servererrors: returns `{ data: null }` so the SPA shows "no error log" until an in-memory ring lands. Adds matching ControlOut variants and removes the three from the bulk-stub `Ack` arm. Also wires AddUserToUserGroup / RemoveUserFromUserGroup as real handlers (helpers mirror add_mesh_user / remove_mesh_user; site-admin gated; both `ugrp.links` and each user's `links` are kept in sync).Swap the bulk-stubbed `adddeviceuser` Ack for a real handler. SPA's wire payload `{ nodeid, nodename, userids|usernames, rights, remove }` now drives: - `add_device_user` (remove=false): looks up the node, derives the meshid, authorizes the caller via `MANAGE_USERS` on that mesh (site-admin already implies this via the meshrights expansion), then writes `links: { <userid>: { rights } }` on both the node doc and each user doc. - `remove_device_user` (remove=true): clears the node's `links[userid]` and the user's `links[nodeid]` back-reference. - `usernames` (short form) are expanded to full `user/<domain>/<lowercased>` ids using the actor's domain so the SPA's "type a username" path works without the caller having to know the full id.- `register_agent_in_db` (agent.rs): when an agent connects with a never-before-seen nodeid, broadcast `event:{action:'addnode'}` so admin tabs append the new device card without a manual refresh. Existing nodes still take the silent-update path, since `lastconnect` already pushes via the connectivity broadcaster. - `edit_user`: broadcast `event:{action:'accountchange', account: <updated doc>}` after a successful db write so the user list / OPM dialog updates live across other admin tabs. Both follow the same coarse-fan-out pattern as the earlier `changenode` / `createmesh` work; visibility filtering happens client-side (`accountchange` is admin-tab-only because the SPA only renders users to admins).Round out the live-tab-sync work for the remaining mutator helpers: - `change_device_mesh`: broadcast `nodemeshchange { nodeid, oldMeshid, meshid }` per moved node so admin tabs reposition the device card under its new mesh bucket. - `create_user_group`: broadcast `createusergroup { ugrp }` so the user-group list grows live. - `edit_user_group`: broadcast `usergroupchange { ugrp }` so renames / re-descriptions reflect on other tabs. - `delete_user_group`: broadcast `deleteusergroup { ugrpid }` so the entry disappears immediately. Same coarse-fan-out pattern as the prior commits; the SPA's per-event handler keys on `ugrpid` / `nodeid` already in its in-memory map and ignores broadcasts for groups it can't see.Wire the same broadcast on the mesh-perms helpers so the access dialog (which reads `mesh.links`) refreshes live across admin tabs. After each successful add or remove, re-read the mesh doc's `links` map and emit `event:{action:'meshchange', meshid, links: <updated>}`. Adds a tiny `mesh_links_clone(state, meshid)` helper so both arms share the read. Adopting `meshchange` for permission updates matches the legacy server's behavior, where the same event also surfaces name/desc edits; the SPA already merges only the fields present in the event payload, so a partial update with just `links` is safe.Per-device ACL edits update `node.links`, which the device-detail "Per-user permissions" UI reads from `nodes[id].links`. After each successful write we now broadcast `event:{action:'changenode', node: <updated>}` so that panel refreshes live across admin tabs without a manual reload.Wire the lib + CLI to cover the high-frequency admin verbs by reusing the existing server-side `ControlIn` -> `ControlOut` pairs: Read-only: - list-nodes -> `nodes` action; flattens the meshid->[node...] map for tabular display. - list-users -> `users` action; admin-only. - list-user-groups -> `usergroups` action; admin-only. - server-info -> `serverinfo` action; pretty-prints the JSON. - user-info -> `userinfo` action. - device-info <nodeid> -> `getsysinfo` action. Mutators (each waits for the server's `Ack { for: <verb>, result, id }` envelope): - add-user / remove-user. - add-device-group / remove-device-group / edit-device-group. - edit-device / remove-device. - broadcast -> `userbroadcast` (msg to every connected user as a `msg/notify`). Implementation: add `roundtrip` and `roundtrip_ack` helpers to `ws.rs` so each subcommand is a one-liner that wraps a JSON payload + expected reply tag. `control_url` and `insecure_rustls_config` are now `pub` so the CLI doesn't need to duplicate the connect dance. The single existing test for the meshes-extract path was rewritten against the new generic `try_match_action` helper.Swap the bulk-stubbed `adduserbatch` Ack for a real handler. SPA's "User Account Import" dialog parses a CSV or JSON upload into `[{user, pass, email?, siteadmin?}, ...]` and posts `{action:'adduserbatch', users: <rows>}`. The new `add_user_batch` helper: - Site-admin gated. - Per-row reuses the existing `add_user` code path (so password hashing, duplicate detection, and the `accountcreate` event broadcast all kick in for each created account). - Returns `(created, failed)` for a summary Ack of `created=N failed=M`; per-row failures log at debug. The SPA's CSV validator already gates obvious shape errors, so we don't abort the batch on the first bad row.SPA listens for `loginTokenAdded` and `loginTokenChanged` events on its case-event switch and refreshes the API-token list across the same user's other tabs. Wire both: - `createLoginToken`: after mint succeeds, push `event:{action:'loginTokenAdded', newToken: {name, tokenUser, created, expire}}` (intentionally without `tokenPass`, matching the SPA's `loginTokens.push(message.event.newToken)` consumer). - `loginTokens` with `remove: [...]`: after the revoke loop, push `event:{action:'loginTokenChanged', loginTokens: <updated>}` with the freshly-listed token set. Both use `push_to_user` (not `broadcast`) since the SPA's handler bails when `event.userid != userinfo._id`, so a per-user push is both correct and the minimum-noise option.Two-piece feature for the SPA's account-image dialog: 1. Control-channel handler `updateUserImage`: real arm now lives in `user_image::apply_update`. SPA sends `{action:'updateUserImage', userid, image}` where `image` is a `data:image/png;base64,...` URL on upload or the literal integer `0` on delete. We: - Auth: self-edit always allowed; admins (siteadmin full or `& 2`) may edit any account. - Upload: validate the data-URL shape, store the URL inline on `user.image`, set `flags |= 1`, bump `accountImageRnd`. - Delete: drop `user.image`, clear `flags & ~1`. - Broadcast `event:{action:'accountchange', account:<updated>}` so other tabs swap the picture without a manual reload (the SPA's `accountchange` already merges per-field). 2. HTTP route `GET /userimage.ashx?id=<short>` (and `/:domain/userimage.ashx`): looks up `user/<domain>/<short>`, decodes the base64 payload of `user.image`, and serves it with the matching `Content-Type` + `Cache-Control: private, max-age=86400`. SPA already cache-busts via `?rnd=<random>` so the long max-age is fine. Returns 404 when the user has no avatar set, 401 without a session cookie. Bytes are kept inline on the user doc instead of as a separate `<datapath>/userfiles/<userid>/portrait.png` (which is what legacy MC does); a typical 256x256 PNG is 30-50 KiB and fits comfortably in the doc body, so a separate per-user filestore for one asset wasn't worth it. Tests: 4 new `split_data_url_*` units; the in-process build stays at 0 warnings beyond the 4 pre-existing unused-field complaints. 334 server-lib tests pass (was 330).The SPA listens for `sysinfohash` and `ifchange` events to invalidate its in-memory sysinfo / network-info caches and re-fetch when an admin is currently viewing the device. Without these events the device-detail panel keeps the stale snapshot until the admin navigates away and back. Wire both into `agent.rs::dispatch_text` next to the cache writes: - After updating `cached_sysinfo` from a `msg/sysinfo` reply, broadcast `event:{action:'sysinfohash', nodeid, hash}` where `hash` is the SHA-256 hex of the hardware blob (the SPA only checks "changed?", not the value). - After updating `cached_netinfo` from a `msg/networkinfo` reply, broadcast `event:{action:'ifchange', nodeid}`. The SPA's handler re-issues `getnetworkinfo` for `currentNode` and ignores the event for any other node. Adds a small `sha256_hex(value)` helper at module scope; the existing `sha2` dep already covers it.Swap the bulk-stubbed `changeemail` Ack for a real handler. SPA's "Email Address Change" dialog posts `{action:'changeemail', email:<new>}` from the account page; until now the server replied `not-implemented` and the dialog's "Change" button silently did nothing. The new `change_email` helper: - Validates the email shape via a small `is_email_shape` check (local@domain.tld, no whitespace) - lines up with the SPA's `validateEmail()` so the server doesn't accept inputs the client refused. - Writes `user.email` and clears `user.emailVerified` (the SPA's verification banner re-appears, matching the legacy MC behavior - the operator must re-verify once the SMTP-side mailer lands; until then `emailVerified` stays false). - Broadcasts `event:{action:'accountchange', account:<updated>}` so the email field on every other tab refreshes immediately. Companion `verifyemail` action (the actual send-the-link flow) stays bulk-stubbed pending SMTP integration.Self-service "remove my phone" / "remove my messaging handle" buttons in the SPA's account dialog. These two actions don't need an SMS or chat-provider call - they just clear local fields - so we can implement them without the messaging-provider infrastructure that gates the verify/confirm/email actions. Adds a small `clear_user_field_pair(state, actor, field, verified_field)` helper that: - Drops the named pair from the actor's user doc. - Broadcasts `event:{action:'accountchange', account:<updated>}` so other tabs reflect the cleared field instantly. Wires: - `removePhone` -> clears `phone` + `phoneVerified`. - `removeMessaging` -> clears `msghandle` + `msghandleVerified`.The SPA's "Power state" panel for each device renders coloured bars per day for the past 7 days and offers a "Download power events" CSV button. Until now `powertimeline` returned an empty array and the CSV route 404'd; the panel just showed nothing. Wire it end-to-end: - New `power_events` module persists rows shaped `{type:'powerevent', nodeid, time, power}` keyed by domain. Captures two transitions today: agent connect (`POWERED`) and agent disconnect (`OFF`). Wider coverage (sleep, hibernate, AMT WSMAN poll) lands when the corresponding subsystems do. - `agent.rs` calls `power_events::record` next to the existing `event_log::record` on connect and disconnect. - `ControlIn::PowerTimeline` reads back via `power_events::timeline`, then `compact_timeline` produces the SPA's `[power, start_secs, power, dur_secs, ...]` wire shape, clamped to the last 7 days. - New `GET /devicepowerevents.ashx?id=<nodeid>` route returns a two-column CSV (`time,power`) for the same window. Hand-rolled `iso8601_utc` formatter avoids pulling in a date library for the one timestamp format we need. Tests: 5 new units (`compact_*`, `iso8601_*`). Lib count 341 -> 347.The `meshcentral-recording` crate (binary `.mcrec` block format matching legacy MC) was already implemented; P5 closes the wiring gap on the *read* side - listing, downloading, and deleting recordings - so the SPA's recordings panel renders correctly the moment a `.mcrec` lands on disk. Capture (the meshrelay-side write path) is gated by the legacy `settings.sessionRecording` config block and lands as a follow-up; for now an empty `recording_dir` produces an empty list, which is the correct behaviour for any deployment that hasn't opted in. Server wiring: - `ServerConfig.recording_dir` and `AppState.recording_dir` (mirrors `files_root` / `backup_dir`). `main.rs` defaults to `<datapath>/meshcentral-recordings`. - New `recordings.rs` module: parses each `.mcrec`'s 16-byte header + JSON `Metadata` body to surface `{name, userid, username, time, size, sessionid}`. Pulls the started-at timestamp from the legacy `<unixms>-<random>.mcrec` basename convention (falls back to mtime). - `meshcentral-web` Cargo.toml gains `meshcentral-recording` as a dep. Control-channel wiring (`user_session.rs`): - `Recordings { nodeid, limit }` -> `recordings::list_for(state, actor, is_admin)`. Site-admin (full or `& 0x40` ops-admin) sees every file; regular users only their own (matched on the recording metadata's `userid`). Sorted newest first. - New `RemoveRecording { file }` action -> `recordings::delete_for`. Same admin-or-owner gate. HTTP wiring (`lib.rs`): - New `GET /recordings.ashx?file=<basename>` route, mirrored under `/:domain/recordings.ashx`. `is_safe_basename` rejects path separators / `..` / `.`-prefixed / non-`.mcrec` names; same admin-or-owner gate as the list path. Streams the file with `Content-Disposition: attachment`. Tests: 2 new units cover the legacy filename allowlist + traversal rejection. Server-lib count 367 -> 369.The agent already accepts `oobupdate` JSON frames (signed SHA-384 digest + URL); P7 wires the server-side issue path so an admin can push a fresh binary at one or more connected agents. Control-channel wiring (`user_session.rs`): - `ControlIn::UpdateAgents { nodeids }` -> `update_agents_push(state, nodeids)`. For each node id we look up the connected agent's `agent.id`, find the matching binary in the catalog, and send `{action:"oobupdate", url:"<external_base>/meshagents/<id>", sha384:<hex>, signature:"", requestid:<rand>}` over the agent's outbound text channel via `state.agents.send_json`. Returns the count of agents pushed to. Site-admin gated; non-admin gets `not-authorized`. - Note (in-source): the agent expects an RSA-PKCS1v15 signature over a SHA-384 of the SHA-384 of the payload, verified against the AuthVerify cert's public key. `meshcentral-pki` issues ECDSA certs by default; until the cert algorithm flips to RSA the agent rejects oobupdate frames with `SignatureUnimplemented`. Framing + URL routing land now; only the signing policy remains. HTTP wiring (`lib.rs`): - New `GET /meshagents/:id` route serves the catalog's pre-loaded bytes for `meshagent-<id>` with `Content-Disposition: attachment`. The `oobupdate` re-fetch path doesn't auth - the digest in the frame binds the bytes to the originating server. - `/:domain/meshagents/:id` mirror.The SPA's "Connect via SSH" device action posts a WS to `sshterminalrelay.ashx`, sends a `{action:"sshauth", username, password, host?, port?}` first frame, and then expects a binary bytestream bridged to a remote SSH shell. Until now the route 404'd; this lands the full bridge. New crate `meshcentral-ssh`: - Wraps `russh = "0.46"` with a "trust on first use" host-key handler. Records the SHA-256 of the server's SSH SPKI (base64 form) on first connect; pinned-hash mode is exposed for production hardening. - `connect_password(host, port, user, pass)` returns an authenticated `client::Handle`. - `open_shell(session, cols, rows)` opens a PTY-backed shell channel. - Re-exports `Channel`, `ChannelMsg`, `client::Msg as ClientMsg` so callers don't need a direct russh dep. - Workspace dep added; `meshcentral-web` consumes via the workspace alias. New module `meshcentral-web/src/ssh_relay.rs`: - `terminal(ws, state, query, headers)` - axum WS upgrade handler. Auth-gated by the SPA session cookie. - After upgrade, reads frames until it gets `{action:"sshauth", ...}`; dispatches `connect_password` + `open_shell` and bridges WS<->SSH bidirectionally. - WS Binary frames -> SSH stdin. SSH `Data` / `ExtendedData` -> WS Binary. Text frames carrying `{ctrl:"resize", cols, rows}` invoke `channel.window_change` so the remote PTY follows the browser's xterm size. - Failure paths emit the legacy SPA-side codes (`autherror`, `connectionerror`, `sessionerror`) as JSON text frames so the existing client handlers display the right message. Routes wired at `/sshterminalrelay.ashx` and `/:domain/sshterminalrelay.ashx`. The matching SFTP-style `sshfilesrelay.ashx` lands as a follow-up - it shares the auth-handshake path but bridges over `russh-sftp` instead of a shell channel.Real changes: - `ControlIn::ScanAmtDevice { range }` now performs a TCP-port-16992 sweep across the requested /24 (or single host) instead of returning an empty list. Each responder yields `{ip, port}`. Site-admin gated. Parallelized via `JoinSet`; per-host timeout 250 ms; 4 ports probed per host. - New `parse_ipv4_slash24` helper accepts `<a>.<b>.<c>.<d>/24` only; larger sweeps would saturate the connect pool and aren't useful for AMT discovery anyway. The remaining specialty URLs (`ipkvm.ashx` / `commander.ashx` / `apf.ashx` / `amtimport.ashx`) get explicit 501 stub handlers carrying a short descriptor + pointer at the matching control-channel action. Each of these needs a substantial new subsystem (Intel AMT redirection protocol client, WSMAN over the same redirection tunnel, the APF inbound listener bridge to the existing `meshcentral-mps` crate, multipart CSV uploader). Stubbing them returns the right HTTP status to the SPA so a future contributor can replace each one in isolation; the existing `importamtdevices` control action already covers the amtimport.ashx-equivalent CSV ingestion via the WS path. `AmtSetupBin` and `Satellite` were already wired (handlers `meshcentral_amt::setupbin::build_password_change` + `handle_satellite` broadcasting `SatelliteResponse`); P9 leaves them alone.Per the rewrite scope, the Rust port doesn't need to keep wire-protocol compatibility with the Node MeshCentral server. Switching the cert algorithm from ECDSA P-256 to RSA-2048 lets the agent's existing oobupdate signature verifier (RSA-PKCS1v15-SHA384 against the AuthVerify cert pubkey, in `meshagent/src/net.rs::on_auth_verify` + `oobupdate::verify_signature`) light up against our pushes without further changes on the agent side. PKI: - `meshcentral-pki/Cargo.toml` adds `rsa = "0.9"` (with `sha2`). - `lib.rs` switches both `KeyPair::generate()` calls (root CA + leaf cert issuer) to `KeyPair::generate_rsa_for(&PKCS_RSA_SHA256, RsaKeySize::_2048)`. - `signing.rs` rewritten: `WebSigner` now wraps an `rsa::pkcs1v15::SigningKey<Sha384>` instead of `ring::EcdsaKeyPair`. `sign(msg)` is RSA-PKCS1v15-SHA384, returning the raw 256-byte signature. New `sign_oob_payload_digest(&[u8; 48])` SHA-384s the payload's pre-computed SHA-384 a second time and signs - the exact pairing the agent's `oobupdate::verify_signature` expects. - Tests updated to verify with `rsa::pkcs1v15::VerifyingKey<Sha384>` + the cert's RSA SPKI pubkey, including a new `oob_payload_digest_round_trip` covering the OOB scheme end-to-end. PKI lib tests: 8 pass. Server: - `user_session.rs::update_agents_push` now signs each frame's payload digest via the WebSigner instead of sending an empty signature. Base64-encodes the 256-byte sig into the `oobupdate` JSON (matching the agent's signature-decode path which expects a base64 string in `cmd.signature`). Refusing to push when `web_signer` is None logs a warning and returns 0. - `agent_handshake.rs` test fixture updated: server-side AuthVerify signature is now verified with `RsaPublicKey::from_public_key_der(spki_der)` + `Pkcs1v15Sign:🆕:<Sha384>()` against the SHA-384 of the signed bytes (matches the agent's `on_auth_verify` exactly). Operator note: existing dev installations need to delete `<datapath>/webserver-cert*`, `agent-cert*`, and `root-cert*` so they get regenerated as RSA on next boot. Fresh installs are unaffected. Workspace test count: 847 -> 848.Two paired changes so the agent installer's `--msh` flag has a real download endpoint: (1) Mesh-id shape alignment (`user_session.rs`): - `create_mesh` now mints 48 random bytes hex-encoded as the mesh short, matching the agent-arrival path's `format!("mesh/<dom>/{}", hex::encode(agent.info.mesh_id))`. Single shape across both creation paths means the .msh's `MeshID=0x<short>` decodes to the exact 48 bytes the agent puts in `AuthInfo.mesh_id` and the server's lookup hits the same row. - New `fresh_mesh_id_hex()` helper next to the existing `fresh_id`. (2) `GET /meshsettings.ashx?id=<short>` route (new `meshsettings.rs`): - Auth: requires a session cookie, gates on `links` membership on the target mesh, site-admin always sees through. - Body shape mirrors legacy MC exactly: ``` MeshName=<group display name> MeshType=<mtype, default 2> MeshID=0x<96-hex> ServerID=<96-hex from WebSigner.pubkey_hash> MeshServer=wss://<host>:<port>[/<domain>]/agent.ashx ``` - `MeshServer` URL built from `state.external_base_url` (https -> wss, http -> ws). Falls back to `wss://localhost/agent.ashx` for dev installs that haven't set the field. - `Content-Disposition: attachment; filename="<safe-name>.msh"` so the SPA's `<a name=...>` link click downloads. Filename is stripped of every char except `[A-Za-z0-9_-]`; collisions are fine since the file body carries the full ids. - Mounted at `/meshsettings`, `/meshsettings.ashx`, `/:domain/meshsettings`, and `/:domain/meshsettings.ashx`. SPA links via the no-extension form; `.ashx` is for direct curl + legacy bookmarks. 7 new unit tests (`is_safe_short` accepts hex+b64-no-pad and rejects traversal, `safe_filename` strips unfriendly chars, `build_ws_url` https/http/per-domain/no-config). Workspace test count: 848 -> 855. End-to-end install flow now works: 1. Admin opens the SPA's "Add Agent" panel for a device group, clicks the .msh link. 2. Browser downloads the rendered config. 3. Admin runs `sudo ./meshagent install --system --msh <file>` (or `--user` for desktop sessions). 4. Agent's first connect lands on the matching mesh row.Companion to the agent's wasm meshcore sandbox. Lets an admin drop a `.wasm` file under `<datapath>/meshcores/`, then push it to selected agents. Catalog (`meshcore_catalog.rs`): - Walks `<datapath>/meshcores/*.wasm` at startup, computes SHA-384, exposes `MeshCoreCatalog::get(name) -> MeshCoreModule`. Bytes are `Arc<Vec<u8>>` so the HTTP route can clone-and-serve without re-reading from disk. - Wired through `boot.rs::load_meshcore_catalog`, `BootedSubsystems.meshcores`, `ServerConfig.meshcores`, `AppState.meshcores`. Test fixtures in `tests/login.rs` + `tests/tls_smoke.rs` updated. HTTP route (`lib.rs`): - `GET /meshcores/:name` (and `/:domain/meshcores/:name`) serves the bytes with `Content-Type: application/wasm`. No auth: the agent's `loadcore` action carries the SHA-384 it expects, so the bytes are bound to the originating server. A rogue tap on the URL just gets a wasm blob it can't impersonate. Control action (`user_session.rs`): - New `ControlIn::PushMeshCore { name, nodeids }`. Site-admin gated. For each connected agent in `nodeids`, signs the module's SHA-384 via `WebSigner::sign_oob_payload_digest` (same RSA-PKCS1v15-SHA384 scheme as `oobupdate`), builds `{action:"loadcore", name, url, sha384, signature, requestid}`, pushes via the agent's outbound text channel. Tests: 4 new units in `meshcore_catalog::tests` (empty-dir, missing-dir, mixed-extension, hash-stability). Server-lib count 859 -> 863.