feat/tunnel-dialer #5

Merged
David merged 48 commits from feat/tunnel-dialer into main 2026-05-07 00:13:21 +02:00
Owner
No description provided.
Two coupled fixes that get the SPA's Desktop/Terminal/Files tunnel
working end-to-end against a connected agent.

Rendezvous handshake. The legacy MeshCentral relay sends a single
ASCII `'c'` Text frame to BOTH sides once both halves are joined.
The SPA's `agent-redir-ws-0.1.1.js::xxOnMessage` blocks at
`obj.State < 3` until it sees that byte; without it the SPA never
emits its protocol byte and the tunnel sits idle. drive_session now
injects `'c'` into the relay (toward the first arriver) and writes
`'c'` directly to the joining side's WS before entering the pump.

Frame-type preservation. pump_socket previously collapsed Text frames
into bytes and re-emitted them as Binary on the peer side, which
breaks the SPA's `typeof e.data == 'string'` dispatch (rtt control
envelopes, the `'c'` handshake byte, JSON command messages).
Introduce a 1-byte tag prefix on the relay channel: `B` = binary
payload, `T` = text payload. pump_socket wraps inbound and unwraps
outbound; tag_frame() helper for synthetic injections.

End-to-end verified via Chrome direct WebSocket test against a Linux
agent: 'c' handshake -> protocol byte echo -> shell prompt bytes
(binary) -> rtt JSON control echo (text). All 325 meshcentral-web
tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the rendezvous handshake, each side emits its protocol byte
(`agent-redir-ws-0.1.1.js`'s `obj.socket.send(obj.protocol)` where
`obj.protocol` is a number 1..9, sent as a Text frame containing a
single ASCII digit). The legacy MeshCentral relay consumes those
bytes and never forwards them to the peer; otherwise xterm renders
a stray `1` and the agent shell receives a stray `1` as a keystroke.

Earlier pass naively ate the FIRST text frame after rendezvous,
which actually swallowed the SPA's options JSON envelope (sent
right before the protocol byte). pump_socket now pattern-matches:
eat exactly one text frame whose body is a single ASCII digit -
the protocol byte - and forward the options JSON normally.

`is_protocol_byte` helper plus a `rendezvous_done` flag that the
first-arrival side flips when it observes `'c'` on its relay rx;
the joining side starts with the flag already set.

End-to-end verified: SPA Terminal panel `connectTerminal(null, 1)`
reaches State 3 with 2ms relay latency, `echo hello-tunnel` runs
on the agent's PTY and the output renders to xterm without leading
`1` artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 four more bulk-stubbed Acks for real handlers:

- serverclearerrorlog: site-admin Ack ok (no in-memory error buffer to clear yet; reply matches the SPA's expectation that subsequent `servererrors` returns null).
- servertimelinestats: replies with `events: []`. SPA's `setServerTimelineStats` renders an empty timeline gracefully until the event-aggregation pipeline lands.
- report: replies with `data: []`. SPA's `renderReport` shows "no rows" for an empty array, so the report-builder dialog stops hanging on a stub.
- intersession: cross-tab broadcast. Forwards the JSON object verbatim back to all connected sessions of the same user via `push_to_user`. SPA currently keys only on `subaction == 'removeNotify'` to dismiss notifications across tabs.

Adds matching ControlOut variants and removes the four from the bulk-stub `Ack` arm.
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.
The SPA's `case 'event'` switch handles `changenode`, `removenode`, `createmesh`, `meshchange`, `deletemesh`, `accountcreate`, and `accountremove` to keep its in-memory `nodes` / `meshes` / `users` maps in sync without a manual page reload. We were writing the database but never broadcasting those events, so a second admin tab (or the same admin's other browser) showed stale cards until refresh.

Wire `state.user_sessions.broadcast(...)` after each successful CRUD on:
- `change_device` -> `changenode` with the patched node doc.
- `remove_devices` -> `removenode` per deleted nodeid.
- `create_mesh` -> `createmesh` with name/desc/mtype/links.
- `edit_mesh` -> `meshchange` with name/desc/mtype.
- `delete_mesh` -> `deletemesh` (mark-deleted, SPA prunes locally).
- `add_user` -> `accountcreate` with the basic account stub.
- `delete_user` -> `accountremove`.

Visibility filtering stays client-side (the SPA already keys updates on whether the id is in its own map), matching the legacy server's coarse-fan-out behavior.
- `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.
Two ashx routes the SPA hits frequently that were 404'ing:

- `GET /serverpic.ashx` and `/:domain/serverpic.ashx`: server-logo image used in the SPA's sidebar header (`<img id=MainMeshImage>` at default.handlebars:596). For now, ship the bundled `images/server-256.png` as the default; the per-domain `titlepicture` override lands when the per-domain config layer does. Without this the sidebar shows a broken-image icon.
- `GET /refresh.ashx` and `/:domain/refresh.ashx`: session keep-alive ping the SPA fires every ~80% of `serverinfo.timeout`. Returns 200 with no body when the session cookie is valid, 401 otherwise. The session-store layer already touches the row on every request, so a bare 200 is enough; no manual cookie rotation needed. Without this the SPA's `refreshCookieSession` errors out and the auth cookie expires under the user.
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`.
Three new unit tests:
- `change_email_writes_and_clears_verified`: verify the happy path writes the new email and resets `emailVerified` to false.
- `change_email_rejects_bad_shape`: confirm `is_email_shape` rejects no-`@`, no-dot-in-domain, embedded whitespace, and empty inputs - mirroring the SPA's own client-side validator.
- `clear_user_field_pair_drops_phone`: cover the `removePhone` / `removeMessaging` shared helper end-to-end (write the pair, call the helper, assert both fields are gone from the doc).

Server-lib test count goes from 334 -> 337.
SPA's `case 'deviceShareUpdate'` replaces its in-memory `deviceShares` with `event.deviceShares` and re-renders the sharing panel when the currently-viewed device matches `event.nodeid`. Without this event the share list only refreshes when the admin navigates away and back.

Wire the broadcast into both helpers:
- `create_device_share_link`: after a successful db put, re-list shares (the helper drops expired entries) and broadcast.
- `remove_device_share`: after a successful db remove, same.

Coarse fan-out matches the rest of the live-tab-sync work; the SPA's handler bails when `event.nodeid != deviceSharesReq` so non-affected tabs ignore the frame.
SPA's per-user file dialog uses `downloadfile.ashx?link=<virtual-path>` for the "Download" button. The `link` value is the file-tree's hierarchical path; for the user's own root the SPA prefixes a `self/` segment that we need to strip.

- Add `download_legacy(?link=<path>)` next to the existing `download(?path=<path>)`. Both share a new `download_inner` that does the auth check + sandbox resolve + read.
- New `strip_namespace(s)` trims a leading `self/` (case-insensitive) or `user/` segment plus stray leading slashes; the rest passes through `safe_resolve` against the user's root, so traversal protection still applies.
- Mount the route at `/downloadfile.ashx` and `/:domain/downloadfile.ashx`.

Companion `uploadfile.ashx` (multipart-form upload) lands separately - it needs `axum`'s `multipart` feature plus auth-cookie validation since the SPA posts via an iframe form rather than from the WS-authenticated SPA.

5 new unit tests for `strip_namespace`. Server-lib count 337 -> 341.
SPA's per-user file dialog posts uploads via an iframe form to `uploadfile.ashx`. The form carries:
- `link` (text): URL-encoded virtual path (e.g. `self/foo`).
- `authCookie` (hidden): the SPA's auth token; the iframe form can't always re-use the session cookie so we authenticate via the cookie payload instead.
- `files` (file[]): one or more uploaded files, with `Content-Disposition` filenames.
- `p5confirmOverwrite` (checkbox, optional): currently ignored.

New `upload_legacy` handler:
- Enables `axum/multipart` feature.
- Streams parts via `axum::extract::Multipart`.
- Validates `authCookie` via `cookie_auth::decode_and_check` and requires the `LOGIN` action tag.
- Reuses `strip_namespace` + `safe_resolve` so traversal protection still applies; rejects filenames with reserved characters or leading `.`.
- Responds with a tiny `<html>ok</html>` body (the iframe target only checks `xhr.status == 200`).

Wired at `/uploadfile.ashx` and `/:domain/uploadfile.ashx`.
Four ControlIn variants accept fields we don't currently consume - they exist for serde-parse compatibility with the SPA's wire shape. Annotate each with `#[allow(dead_code)]` and a one-line comment explaining the field's purpose so a future contributor knows whether to remove it or wire it.

- `GetSysInfo.nodeinfo`: SPA's "no-info" probe variant; we always return the cached body so the field is informational.
- `UpdateAgents.nodeids`: dispatch still replies `not-implemented`; field reserved for when self-update push wires through.
- `AddDeviceUser.nodename`: SPA sends the display name for audit context; we log via event_log keyed on nodeid instead.
- `ServerTimelineStats.hours`: window in hours; we ship `events: []` until aggregation lands.

Brings `cargo build -p meshcentral-web` from 4 lib warnings down to 0 (the lone remaining warning is the cargo-config dir collision, which is environmental).
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.
Round out the CLI's verb coverage by mapping more existing server actions:

- `notify-user <userid> <message> [--title]` -> `notifyuser`. One-shot push to a single user (site-admin only).
- `add-mesh-user <meshid> <userid> --rights <bits>` -> `addmeshuser`.
- `remove-mesh-user <meshid> <userid>` -> `removemeshuser`.
- `move-to-device-group <nodeid> <meshid>` -> `changeDeviceMesh`.
- `add-user-to-device <nodeid> <userid> [--rights <bits>] [--remove]` -> `adddeviceuser`.
- `power-action <nodeid> <actiontype>` -> `poweraction` (legacy numeric codes: 2=wake, 4=reboot, 8=shutdown, 11=sleep, 12=hibernate).
- `list-events [--nodeid] [--userid] [--limit]` -> `events`. Tabulates the event log the caller can see.

Each is a 1:1 wrapper around `roundtrip_ack` (or `run_action` for the typed `events` reply); no new server-side work.
Three new unit tests for the multipart-upload helper that does the actual filesystem write:

- `write_one_creates_dirs_and_file`: happy path with a `self/Documents` link; verifies the file lands at the expected per-user location.
- `write_one_rejects_traversal_in_link`: a `../../etc` link returns `Traversal` instead of writing.
- `write_one_rejects_bad_filename`: every reserved character / leading-dot filename returns `BadName` (or `Traversal` for the leading-`..` case) instead of writing.

End-to-end multipart parsing + auth verification stays out of scope here; covering those would require a full router fixture matching `tests/login.rs` setup.
The `meshcentral-messaging` crate already shipped a working `Mailer` (lettre 0.11 over rustls) and the `AppState.mailer` slot was already declared, but no construction site existed. This change closes that gap and lights up four control actions that had been stubbed.

Boot wiring:
- `boot.rs`: new `read_smtp_config` reads `settings.smtp` -> `SmtpConfig` -> `Mailer::from_config`. Failures log + return None so a malformed section doesn't stop bring-up. `read_sms_config` lands in the same spot for P2.
- `boot.rs::BootedSubsystems` carries the new `mailer` + `sms_sender` fields.
- `meshcentral` crate gains `meshcentral-messaging` dep (features `mail`, `sms`).
- `main.rs` plumbs `booted.mailer` into `ServerConfig.mailer` and adds `read_external_base_url(config)` so the renderer can land absolute URLs in invitation emails.

Control-channel wiring (`user_session.rs`):
- New `mail_codes` module: 6-digit code mint + 15-min TTL persisted on `<scope>/<userid>` docs (`emailcheck` for verify, `otpemail` for login). Single-use: doc is dropped on a successful confirm. Mailerless deployments still mint + persist the code (admin can read it) and reply `no-mailer` so the SPA can show a "set up SMTP first" hint.
- `verifyemail` -> mints code -> `Mailer::send_text` -> persists doc.
- `verifyemailcode` -> single-use confirm -> flips `user.emailVerified=true` -> broadcasts `accountchange`.
- `otpemail` -> refuses unless the user is already email-verified, then mints + sends.
- `otpemailcheck` -> single-use confirm; the broader login flow records the MFA-passed bit elsewhere.
- `emailuser` -> admin sends an arbitrary email via the mailer; site-admin gated.

Tests: 5 new units cover mailerless degrade, code mismatch, happy-path verify (flips emailVerified, drops the code doc), unverified-email refusal for OTP, admin gate for emailuser. Server-lib count 350 -> 357.
The `meshcentral-messaging` crate's `SmsSender` (Twilio + Plivo + Telnyx providers) was already implemented; the field was already on `AppState.sms_sender`. P2 closes the wiring gap and lights up the three SMS-driven control actions that had been stubbed.

Boot wiring:
- `boot.rs::read_sms_config` reads `settings.sms` -> `SmsConfig` -> `SmsSender::from_config`. Same forgiveness as `read_smtp_config`: a malformed section logs + returns None, the boot loop continues.
- `main.rs` plumbs `booted.sms_sender` into `ServerConfig.sms_sender`.

Control-channel wiring (`user_session.rs`):
- New `phone_codes` module mirrors `mail_codes` but sends through `state.sms_sender` and stores on `phonecheck/<userid>` docs. Same single-use semantics: doc dropped on a successful confirm. SMSless deployments still mint + persist the code so an admin can read it from the DB; caller sees `no-sms`.
- `verifyPhone` -> `is_e164` shape check -> mint -> persist -> SMS.
- `confirmPhone` -> single-use confirm -> writes `user.phone` + `phoneVerified=true` -> broadcasts `accountchange`.
- `smsuser` (admin) -> sends free-form SMS to a target user's verified phone; refuses unverified numbers and missing SMS sender configs.

Tests: 5 new units cover bad-E.164 reject, no-sms-degrade-still-persists-code, happy-path confirm (writes phone + phoneVerified, drops the code doc), admin gate for smsuser, e164 shape edge cases. Server-lib count 357 -> 363.
The `meshcentral-messaging::chat::ChatPoster` (Slack / Discord / Telegram / Pushover / Zulip webhooks) was already implemented; P3 wires it into `AppState` and lights up the three SPA messaging actions that were stubbed.

Boot wiring:
- `boot.rs::read_chat_config` reads `settings.chat` -> `ChatConfig` -> `ChatPoster::from_config`. Same forgiveness as the mailer / SMS readers.
- `boot.rs::BootedSubsystems.chat_poster` carries the new field; `main.rs` plumbs it into `ServerConfig.chat_poster`.
- `meshcentral` and `meshcentral-web` enable the `chat` feature on `meshcentral-messaging`.
- New `AppState.chat_poster` slot (mirrors `mailer` / `sms_sender`).

Control-channel wiring (`user_session.rs`):
- New `chat_codes` module mirrors `mail_codes` / `phone_codes` with a `msgcheck/<userid>` doc shape; chat poster sends a server-wide message (handle + code) since most webhook providers (Slack / Discord) can't address an individual recipient at the protocol level. Telegram-style per-user `chat_id` routing is a follow-up.
- `verifyMessaging` -> mint code + post.
- `confirmMessaging` -> single-use confirm -> writes `user.msghandle` + `msghandleVerified=true` -> broadcasts `accountchange`.
- `msguser` (admin) -> posts `@<handle>: <body>` to the chat poster; site-admin gated; refuses unverified handles + missing chat config.

Tests: 3 new units cover no-chat-degrade-still-persists-code, happy-path confirm (writes msghandle + verified, drops the code doc), admin gate for msguser. Server-lib count 363 -> 366.
Duo Push 2FA was the last messaging-style stub; Yubico's `OtpHkeyYubikeyAdd` arm was already implemented and only needs the matching `settings.yubico` block (also already read in `read_yubico_config`).

Boot wiring:
- `boot.rs::read_duo_config` reads `settings.duo` -> `DuoConfig`. Forgiving on malformed input, info-logs the configured api_host on success.
- `boot.rs::BootedSubsystems.duo` carries the new `Arc<DuoConfig>`; `main.rs` plumbs it into `ServerConfig.duo` (was hardcoded `None`).

Control-channel wiring (`user_session.rs`):
- `OtpDuo` -> `otp_duo_push` triggers a Duo push for the actor's short username, then polls `auth_status` in 1.5s intervals up to a 60-second deadline. Maps Duo's `Allow` / `Deny` / `Waiting` to `"ok" / "denied" / "timeout"` (plus `"no-duo" / "push-failed" / "poll-failed"`). Matches the legacy MC behaviour: the SPA's login flow blocks on this single Ack.

Tests: 1 new unit confirms `no-duo` when unconfigured. The full Duo round-trip stays an integration test against Duo's API and runs out of band. Server-lib count 366 -> 367.
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 `meshcentral-plugins` crate already shipped a working wasmtime sandbox (v1 + v2 ABIs, fuel-limited, host-import trait). P6 wires the dispatch path the SPA's `Plugin` action expects, plus adds the read-only HTTP admin index.

Catalog wiring (`plugin_catalog.rs`):
- New `find(name)` returns the discovered plugin entry by manifest name.
- New `wasm_path(name)` resolves to `<root>/main.wasm` (then `<root>/<name>.wasm` as fallback) so the dispatcher can load bytes off disk.

Control-channel wiring (`user_session.rs`):
- `ControlIn::Plugin` now carries a `payload` flatten bucket so the SPA can pass arbitrary JSON to the plugin guest.
- New `dispatch_plugin` helper looks up wasm bytes, calls `PluginInstance::load_bytes` + `on_event_json`, returns one of `ok` / `not-found` / `load-failed` / `dispatch-failed` / `abi-mismatch`. Site-admin gated.
- `NoopBackend` impls every `HostBackend` method as a benign default - real per-permission `db_get` / `db_put` / `dispatch_event` plumbing lands when a specific plugin needs it; the dispatch gate already keeps wasm sandboxed by default.

HTTP wiring (`lib.rs`):
- New `GET /pluginadmin.ashx` (and `/:domain/pluginadmin.ashx`) returns the catalog manifests as JSON. Site-admin only.

Lib-test count unchanged at 369 (the dispatch path is best covered by an integration test against a real `.wasm` plugin, which lands as a follow-up; the existing `meshcentral-plugins` crate already covers the sandbox internals exhaustively).
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.
- `meshcentral/src/service.rs`: real `install/uninstall/start/stop/restart` for FreeBSD / DragonFlyBSD / OpenBSD / NetBSD using the standard rc.d-script pattern. Picks `/usr/local/etc/rc.d` on FreeBSD/DragonFly, `/etc/rc.d` on OpenBSD/NetBSD. The script body wires `meshcentral_enable` rcvar so admins can toggle via `service meshcentral enable`. Solaris and other Unixes fall through to the existing "not yet implemented" arm.
- `meshcentral-mps/src/session.rs`: replace the bare `TODO: real flow control` with a comment documenting why the no-op is correct in practice (AMT CIRA traffic stays well under the default window) and what to wire when production load gets near it.
- Test fixtures (`tests/login.rs`, `tests/tls_smoke.rs`): add the new `chat_poster` + `recording_dir` fields that landed in P3 / P5 to the inline `ServerConfig` literals so the integration tests compile + run.

The remaining P10 items (Authenticode signing for Windows agent binaries, SAML-response signature verification, meshctrl `--token` / loginkey flow) each need substantial new infrastructure (full PE-file manipulation, full XML-c14n + sig verify, server-side login-token auth on `/login`); they get their own dedicated PRs.

Workspace test count: 847.
Closes the long tail of SPA-referenced ashx URLs that previously 404'd. Each gets a route + a short hint pointing at the working WS-side equivalent so a future contributor can see the matching subsystem without grep-hunting.

Real wiring:
- `/relay.ashx` - alias for the existing `/meshrelay.ashx` rendezvous WS handler. Some older SPA builds + scripts still reference the short URL.

501 hints (each carries a one-line descriptor identifying the matching control-channel action):
- `/control-redirect.ashx` - 200 OK with a "handled inside /oidc/callback" note. The Rust port's OIDC flow already terminates at the callback URL.
- `/uploadfilebatch.ashx`, `/uploadmeshcorefile.ashx`, `/uploadnodefile.ashx` - specialty multipart targets that need per-flavour multipart parsers; the matching control-channel actions already work.
- `/oneclickrecovery.ashx`, `/restoreserver.ashx` - admin disaster-recovery uploads; `serverBackup` already covers backup *capture* over the WS channel; restore + recovery-image upload need their own dedicated PRs (auth + tarball signing).
- `/devicefile.ashx` - per-device file download. Real impl reuses meshrelay.ashx + the agent's `file_get`; the standalone HTTP route would short-circuit that pipe and isn't wired yet.

Workspace tests: 847.
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.
`POST /restoreserver.ashx`: real handler. Multipart `auth` (cookie_auth token) + `datafile` (the JSON file `serverBackup` produces) -> validate the auth-cookie payload, gate to full site-admin, parse the body as a JSON array of doc bodies, clear every existing doc out of the DB, then `db.put` each parsed body. Replies with a tiny HTML body for the iframe target carrying the `wrote: N, removed: M` summary so the SPA's `xhr.status == 200` plus the body text both signal success.

The other deferred P12 routes need substantial new infrastructure (per-device meshrelay tunnel allocator + agent file_put bridge for `uploadfilebatch.ashx` / `uploadnodefile.ashx` / `devicefile.ashx`; AMT redirection-protocol client for `oneclickrecovery.ashx`). They each get a refined 501 hint pointing at the matching path the SPA already exercises:

- `oneclickrecovery.ashx` - now correctly identified as Intel AMT One Click Recovery (.efi boot upload), not server backup. Hint mentions the AMT redirection-protocol client requirement.
- `uploadmeshcorefile.ashx` - new dedicated hint: the Rust agent doesn't execute JS meshcore modules, so this URL won't be wired even when its multipart parser lands.
- `uploadfilebatch.ashx` / `uploadnodefile.ashx` - shared hint pointing at the matching control-channel action.
- `devicefile.ashx` - hint points at the meshrelay tunnel + file_get path.

Workspace test count: 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.
Server is Docker-only. The `Install` / `Uninstall` / `Start` / `Stop` / `Restart` subcommands and `crates/meshcentral/src/service.rs` (~250 lines covering Linux/Windows/macOS/BSD service installers) are removed; deployment is via `compose.yml` and the container runtime supervises the process. The agent installer in the sibling repo still ships systemd units - that's the right place for them since agents run on user-managed hosts.

ROADMAP.md rewritten as a 2026-05-06 snapshot:
- Done section enumerates every wired path (auth, messaging, agent connect, tunnel/KVM, file storage, ControlIn handlers, plugins, agent self-update push, SSH proxy, server infra, HTTP routes, agent installer, meshctrl).
- Remaining work ranked: 6 tractable items (sshfilesrelay, file-tunnel allocator, meshctrl --token, ServerUpdate rename, recording capture, wasm meshcores) + 3 bigger subsystems (AMT redirection, Authenticode, SAML) + acknowledged limitations + explicit out-of-scope decisions (server systemd, ServerUpdate, JS meshcores, wire-protocol compat with Node MC).
- New "JS-cores replacement" section: full design for wasm-pushed agent extension modules. Reuses the existing `meshcentral-plugins` wasmtime sandbox, the `oobupdate` signing scheme, and the same trust chain. Includes the wire shape, server + agent scope, permission model, effort estimate (~3 days), and open questions. Independent landing order suggested: server catalog → agent loader → dispatch wiring.

Test count unchanged at 855.
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.
docs: ROADMAP + RMM update for wasm meshcore
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
1422ec6d90
ROADMAP changes:
- Snapshot bumped to 859 server tests + 245 agent tests, 55 commits ahead of main.
- New "Wasm meshcore (agent extensibility)" section under Done, summarising the catalog + push path on the server side and the loadcore handler + slot + dispatch forwarding on the agent side.
- Drops the now-stale "JS-cores replacement" design section (the wasm meshcore landed; that section was the proposal it became).
- Remaining work item #6 reframed: from "wasm-pushed agent extension modules" to "wasm meshcore host imports for richer extensions" - the 1-day follow-up work to grow `db_get` / `db_put` / `dispatch_event` / gated `fetch_url` so guests can do more than register new verbs.
- Out-of-scope row for `uploadmeshcorefile.ashx` updated to point at the wasm meshcore replacement.
- New "Companion docs" section pointing at `RMM.md` and explaining the relationship: wasm meshcore covers the "add a verb" lightweight cases; the subprocess job-runner in RMM.md is preserved for when RMM features land.
- New row in Out of scope: subprocess job-runner plugin model is "out of scope for the near-term PSA; design preserved in RMM.md".
David merged commit 1f9187c85d into main 2026-05-07 00:13:21 +02:00
David deleted branch feat/tunnel-dialer 2026-05-07 00:13:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-server!5
No description provided.