mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
d369b03a38e19d1e2fa7ee98f4e6738fce5da3ef
358 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d369b03a38 |
feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions Add a backend idle-output backstop that stops a deploy/update compose step that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a hung image pull surfaces a fast failure instead of spinning indefinitely. Surface failed, timed-out, and stalled operations with recovery actions on the stack page: a desktop chip plus popover menu and an inline mobile card offering retry, restart, roll back (when a backup exists), refresh state, and copy diagnostics, all gated by deploy permission. The streaming deploy/update progress modal is now on by default and warns when output goes quiet. Container state is refreshed after a failed or stalled operation, and the UI never sits in an indefinite spinner. * fix: harden rollback against policy-blocked file mutation and refine recovery Address review findings on the stalled-update recovery work: - The rollback route restored backup files before running the policy gate, so a policy-blocked rollback could leave the on-disk config rolled back while the deployed containers were unchanged. Snapshot the current files first and revert them when the gate blocks; if that revert itself fails, escalate it on the persistent alert feed since the 409 is already sent. - Refresh container state after a successful manual rollback (rollback redeploys), without mis-recording a refetch failure as a rollback failure. - Suppress the stalled-output warning once live progress is unavailable. * test: mock snapshotStackFiles in the atomic-deploy rollback route tests The rollback route now snapshots stack files before restoring a backup, so its FileSystemService mock needs snapshotStackFiles. Without it the mocked call threw and the route returned 500, failing the success-path rollback assertions. |
||
|
|
e7895c889d |
fix: base Stack health uptime on container start, not creation (#1341)
The dashboard Stack health UP column counted from each container's Created timestamp, which never moves on stop/start or restart, so a restarted container kept reporting its original age. Resolve uptime from State.StartedAt (via a briefly cached inspect with bounded concurrency, falling back to Created when inspect is unavailable) so it reflects the real time since last start. The current CPU and MEM columns separately summed the latest sample per container with no recency filter, letting a recently stopped container's final reading linger in the totals. Drop samples that trail the freshest sample by more than the stale window so stopped containers leave the sum. |
||
|
|
710647a44f |
feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)
* feat(snapshots): preserve stack dossiers with fleet snapshots Fleet snapshots can now optionally capture each stack's Dossier notes alongside its compose and .env files, so a recovery restores the operational knowledge around a stack, not just its configuration. - Opt-in global setting "snapshot_documentation" (default off), toggled from the renamed Fleet settings section. - Capture reads local dossiers from the database and remote dossiers over the Distributed API proxy; only stacks with notes are recorded, and secret values are never included. - Captured notes are stored encrypted at rest in a new fleet_snapshots column and surfaced in the snapshot detail view behind a badge. - Cloud and downloaded archives gain a documentation.json (archive_version 2). - Restore stays conservative: dossier notes are written back only when the operator explicitly opts in, on both single-stack and restore-all paths. - Existing snapshots and archives remain valid; behavior is unchanged when the setting is off. * fix(snapshots): harden dossier-notes restore against bad input and partial failures Address review findings on the documentation-snapshots restore path: - Parse `restoreNotes` strictly (=== true) on single-stack restore, matching restore-all, so a stray non-boolean can never opt in to overwriting notes. - Guard findSnapshotDossier: require an array of stacks and real dossier content, so a malformed or all-blank entry can't clobber current notes. - Make the dossier-notes write non-fatal relative to the file restore: a notes failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and no longer 500s the single restore or fails the stack in restore-all once the files are already written. - Surface the partial outcome in the UI: a warning toast on single restore, a summary note on restore-all, and gate the "Documentation captured" badge and restore-all notes control on captured stacks while rendering capture warnings. Adds tests for strict parsing, malformed/blank blobs, remote notes restore (success + non-fatal failure, single and bulk), and scheduled capture-on. * fix(snapshots): drop unused binding in restore-all remote notes test The restore-all remote notes test destructured a node id it never uses (restore-all is driven by snapshot id alone), tripping no-unused-vars and failing the lint step. Bind only the snapshot id. |
||
|
|
b21324f97a |
feat(stacks): persist a drift ledger with temporal source-change detection (#1333)
* feat(stacks): persist a drift ledger with temporal source-change detection Build on the read-only compose-vs-runtime drift check so a stack's drift is remembered over time, not just shown at a glance. - Record a deploy baseline: on a successful deploy, update, or rollback, store the deployed compose file's source and rendered-model hashes on the stack so the Drift tab can tell whether the file has changed since the last deploy. - Surface temporal drift in the Drift tab: "matches last deploy", "source changed since last deploy" (distinguishing a model change from a formatting-only edit), or "no deploy baseline yet". - Persist findings into a drift ledger: a re-check reconciles the current findings, recording newly detected ones and resolving cleared ones, and shows a short drift history under the findings. The drift report read stays side-effect-free; only an explicit re-check (and a deploy) writes the ledger. - Write drift detected/resolved events to the stack Activity timeline so the provenance sits alongside deploys and restarts. Node-local and available on the Community tier. Reconciliation is skipped when a check is not authoritative (Docker unreachable or a compose parse error) so an open finding is never falsely cleared. * fix(stacks): record the drift baseline for every deploy path and harden the ledger Address review feedback on the drift ledger: - Record the deploy baseline in ComposeService.deployStack/updateStack instead of only the manual route, so bulk, Git-source, App Store, scheduler, and webhook deploys all capture source/rendered hashes. Reconciliation stays on the explicit re-check. - Store no rendered baseline when the local parser cannot model the compose (for example a file over the parse cap) rather than a sentinel that would make a later real change read as unchanged. - Let temporal-overlay failures surface as a 500 instead of being hidden behind a neutral "no baseline"; only the compose read stays best-effort. - Omit the temporal card entirely when a report (for example from an older remote node) carries no temporal data, instead of showing a misleading "no baseline". - Keep drift_detected / drift_resolved history-only by excluding them from the routable-category whitelist, so they are never offered as a channel route that would never fire. - Use a JSON separator for the finding identity key so the source file is plain text (no embedded control byte). * fix(stacks): sanitize logged errors in the drift report handlers The drift report and re-check handlers logged the caught error object raw alongside the stack name, which a code scan flagged as a log-injection vector: a crafted stack name surfacing inside an error message or stack could forge log lines. Route the error through the log sanitizer so control characters are stripped before writing. Render it with util.inspect first so the stack trace, cause chain, and underlying error codes are preserved for debugging. |
||
|
|
421177e4a6 |
feat(stacks): add compose-vs-runtime drift detection (#1329)
* feat(stacks): add compose-vs-runtime drift engine Add a read-only engine that compares a stack's on-disk compose model against the live Docker runtime and reports where the two diverge. GET /api/stacks/:stackName/drift returns a per-stack report with a status (in-sync, drifted, missing-runtime, unreachable) and typed, service-scoped findings: a declared service with no running container, a running container not declared in compose, an image mismatch, and a published-port mismatch. The report is computed at request time with no persistence and is available on every tier. The check reuses the existing compose parser and Docker dependency snapshot; the compose parser now also captures each service's declared image. Boundaries fail closed: an unreadable compose file reports drifted and an unreachable Docker daemon reports unreachable, never a false in-sync. * fix(stacks): keep drift hasContainers accurate on compose parse error assembleStackDrift hardcoded hasContainers: false on the parse-error path, contradicting the field's contract when the runtime actually has running containers. Compute it once from the container set and reuse it across all return paths. Also add a route test that exercises the successful 200 path for an existing stack on the Community tier (stubbing only the Docker boundary), so a tier gate or handler regression after the existence check is caught. * feat(stacks): add a drift detection tab to the stack view Surface the compose-vs-runtime drift report on the per-stack Anatomy panel as a read-only Drift tab. It shows the stack's status (in sync, drifted, not running, unreachable) and, when drifted, the specific service-scoped reasons with the declared and running values side by side. A re-check action reruns the comparison. The tab lives in the shared anatomy panel, so it appears on both the desktop stack view and the mobile stack detail. Available on every tier. * fix(stacks): sanitize the logged error in the drift report builder The compose-read and Docker-snapshot catch blocks logged the raw error object, whose message can embed the user-controlled stack path (e.g. an ENOENT path). Log the error through the existing sanitizer so a crafted stack name cannot forge log lines, matching the pattern used elsewhere in the stacks router. |
||
|
|
57fe430db8 |
feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written. |
||
|
|
af4083175c |
feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab Add a fleet-wide Dependencies tab to Fleet view that maps how stacks, services, networks, volumes, and ports relate, with flags for missing dependencies, port conflicts, orphaned resources, and cross-stack shared resources. Read-only; filterable by stack, node, and flag; collapsed by default with a list-view fallback at scale. The graph is derived at request time from Docker and compose metadata, so no new table or persisted state is introduced. A per-node graph endpoint feeds a hub aggregation endpoint that fans out across the fleet and degrades gracefully, surfacing unreachable or unparseable nodes inline while the rest of the map still renders. * fix(fleet): harden dependency map flag detection and remote merge Address review findings on the dependency map: - Port-conflict detection now does pairwise host-scope overlap, so an unrelated bind on the same port and protocol but a different specific host IP is no longer flagged, and the flag lands on the exact scoped port node. - A running service's depends_on target is only considered satisfied when it is actually running, so a crashed (exited) dependency is surfaced while a deliberately stopped stack stays quiet. - Declared external networks and volumes are reported missing when they do not exist on the host instead of being assumed present. - The hub deep-validates each remote node-graph payload before merging, so a reachable-but-malformed remote degrades to a single node error rather than failing the whole fleet map, and the validation failure is logged. - Searching or filtering on a network, volume, or port now also reveals the services that claim it and their stacks. |
||
|
|
86bfc108ae |
fix(security): resolve open CodeQL path-injection and temp-file alerts (#1322)
Re-establish the path-containment barrier inline at the backup readdir sink in restoreStackFiles. The sink previously built backupDir through the getBackupDir helper, whose stack-name validation the static analyzer does not trace, leaving a flagged path-injection sink. The barrier now resolves backupDir against its root and asserts containment inline, the same pattern already used in backupStackFiles. Behavior is unchanged for valid stack names (already validated one line above by resolveStackDir). Scope the js/insecure-temporary-file rule out of e2e/** in the CodeQL config. End-to-end fixtures must seed files into the backend's COMPOSE_DIR so the API under test can read them back; that path is a fixed location under /tmp in both CI and local dev, which the rule flags. A randomized temp directory does not apply because the backend resolves against its own COMPOSE_DIR. Production code is still analyzed. |
||
|
|
f7f3afe05a |
feat(stacks): one-click import for stray compose files (#1320)
* feat(stacks): move discovered import candidates into place The guided import flow previewed loose and nested compose files but could not act on them, so it only told the user where to move files by hand. Add an opt-in "Move into place" action: relocate a loose-root file into its own <name>/ subfolder, or promote a nested stack directory one level up, so Sencho's filesystem discovery lists it as a stack. The file stays a plain compose file on disk; nothing is captured into a store. The move re-derives the candidate from a fresh scan and matches by location, validates the destination name and containment, resolves symlinks before the rename, and never overwrites an existing stack. Backend and frontend both gate the action on stack:create. Also fix the rescan flicker: scan results now stay on screen while a rescan runs (only the Rescan button shows progress) instead of the whole panel collapsing to a spinner, and an empty rescan surfaces a toast. * fix(stacks): make import-move destination creation atomic The loose-root branch created the destination directory with mkdir recursive after an access() existence precheck. If the destination appeared between the check and the create, recursive accepted the existing directory and the following rename could overwrite a same-named compose file inside it, so the intended conflict response never fired. Use a non-recursive mkdir so a destination that already exists raises a conflict instead of being merged into. Add a regression test that forces the precheck to miss and asserts the existing file is left intact. * fix(stacks): only offer not-yet-imported compose files in the import tab The import tab listed every compose file in the compose directory, including ones that are already stacks (a top-level subfolder with a compose file), which just duplicated the sidebar. The scan now skips those and surfaces only files that still need importing: a compose file loose at the compose-dir root, or one nested a folder too deep. Also harden the move-into-place write path that turns a stray file into a stack: a failed rename after the destination folder is created now rolls back the empty folder, so a retry is not blocked by a false "already exists" conflict, and the move switches on an exhaustive set of placements so a new one cannot silently take the wrong branch. The sidebar refreshes after a move so the imported stack appears right away, and the docs describe import as relocating a file, not capturing running containers. * fix(stacks): reject a nested import whose compose file escapes the base The move-into-place path for a nested compose file validated only the parent directory's real path, not the compose file itself. A directory that is real and inside the compose base but holds a compose file symlinked outside the base would survive the directory move and become a stack whose compose file still points outside the base, which the editor read path would then follow. The move now resolves the compose file too and refuses it unless it stays inside the resolved source directory, matching the loose-root check and the scan's preview reader. * fix(stacks): satisfy CodeQL path and log analysis in import-move The import-move write path built its destination directory from the user-provided stack name through resolveStackDir, whose containment barrier is wrapped in a helper that static analysis does not credit, so every filesystem sink on the destination was flagged as path injection. Re-establish the resolve-against-the-safe-base plus startsWith barrier inline at the sinks, matching the read and backup paths in the same file, and route the relocated file path through the same check. The name is already restricted to an alphanumeric, hyphen, and underscore allowlist, so the containment can never actually fail; this only makes the existing safety visible to the analyzer. Also log the move route's error as a sanitized message rather than the raw error object, so a name embedded in an error message cannot forge log lines. |
||
|
|
308949282c |
feat(resources): reclaim banner controls and accurate reclaim math (#1318)
* feat(resources): reclaim banner controls and accurate reclaim math Make the Resources Hub reclaim banner match what it advertises and give operators control over when it appears. - "Review & prune" now reclaims every category the banner lists (unused images, stopped containers, and dangling volumes) instead of images only, so the banner clears in one action. Pruning runs volumes first, while stopped containers still reference their named volumes, so a stopped stack's data is never cascaded into deletion. - Add a "Show reclaimable-space banner" toggle under Settings, System, Docker hygiene (on by default, per node) and a dismiss control on the banner that snoozes it until the reclaimable total grows again. - Fix the reclaimable-space math: count only containers a prune can actually remove (created, exited, dead) and size them by their writable layer, so a small, un-prunable remainder no longer keeps the banner up. * fix(resources): show the reclaim banner when the settings fetch fails A failed or empty /settings load left the banner's enabled flag at the previously active node's value, so switching from a node with the banner turned off to a node whose /settings errored kept the new node's banner hidden. Set the flag unconditionally after the staleness guard so a failed fetch falls back to the default-on state for the current node. |
||
|
|
716daf77d0 |
feat(updates): auto-prune dangling images after updates (#1316)
* feat(updates): auto-prune dangling images after updates Each update pulls a fresh image and recreates containers, leaving the replaced image behind as a dangling layer that previously had to be pruned by hand. A new "Prune dangling images after updates" toggle under Settings > System > Docker hygiene reclaims these automatically. The setting is on by default and opt-out. When enabled, a successful stack update (manual or scheduled) and a Sencho self-update each remove the dangling image layers they orphaned. Only untagged layers are touched; tagged images, volumes, and data are never removed. The toggle requires an admin account and is per node: each instance honors its own value, so a remote node self-update applies that node's own preference. A prune failure never affects the update result: on the stack path it is caught and logged after the update has already succeeded, and on the self-update path the helper-shell prune runs only after a clean recreate and cannot change the exit code or the recorded update error. * security(self-update): shell-quote label-derived values in helper command Address review feedback on the prune-on-update change: - The self-update helper command interpolated the compose service name and config-file paths (both read from Docker Compose labels) straight into a shell string. Shell-quote them via shQuote so a label carrying shell metacharacters stays inert data and cannot break the exit-code capture, error-file write, or prune guard. - Correct the settings copy and docs: the prune is a standard dangling-image prune, so it reclaims every untagged layer on the node, not only the one the current update orphaned. Tagged images, volumes, and data remain untouched. - Add tests: shell-metacharacter neutralization and prune-output suppression in the self-update command, and an atomic-update case asserting a prune failure does not trigger a rollback. * fix(updates): omit the reclaim figure when the daemon reports zero bytes End-to-end testing on a Docker daemon backed by the containerd image store showed the post-update prune removing a dangling image while the prune API returned SpaceReclaimed=0, so the stream printed "reclaimed 0.0 MB" even though an image was removed. Show the reclaimed figure only when the daemon reports a non-zero value; otherwise the line reads "=== Pruned dangling images ===". The overlay2 store still reports real figures and shows them. Add a test covering both branches. |
||
|
|
865d792874 |
feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists. |
||
|
|
42fc8048fe |
fix(metrics): host memory usage excludes reclaimable page cache (#1297)
* fix(metrics): host memory usage excludes reclaimable page cache Host RAM was computed as mem.used / mem.total via systeminformation, but mem.used counts reclaimable buffers/cache as used, so a busy Linux host read ~99%. Switch the dashboard stats, the fleet node self-report, and the host-RAM alert threshold to mem.active / mem.total (cache-excluded), and report used: mem.active and free: mem.available so the byte readout stays consistent with the percentage. Add regression tests for a cache-heavy host (no false alert) and a genuinely busy host (alert still fires). * test(metrics): assert system stats memory excludes reclaimable cache The cached /api/system/stats test mocked si.mem() without active/available, so after the route switched to the cache-excluded fields it produced a NaN percentage that the shape-only assertions did not catch. Fill the mock with a realistic shape and assert the route reports the active working set, not the cache-inclusive used/free. |
||
|
|
5289f01bfd |
feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker Add a preflight that checks whether the host can run Docker deploys before a deploy fails for an avoidable reason. It verifies the Docker engine is reachable and permitted, the Compose plugin is present, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and the compose volume has disk headroom. Each result that needs attention carries a specific fix rather than a generic error, and the checks never block: an operator who knows their setup can continue. The checks run as the final step of first-boot setup and can be re-run any time from the Recovery settings tab. A new admin-only endpoint, GET /api/diagnostics/environment, backs both surfaces. * fix(onboarding): distinguish unverified path mapping and support parent binds Treat a container whose self-inspect fails as an unverified path-mapping warning instead of a false "not containerized" pass, so an unverifiable mapping never reads as healthy. Resolve the compose directory through the longest-prefix bind mount and compare the host path it resolves to, so a parent bind such as -v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that the directory is not bind-mounted. * test(e2e): advance the setup wizard past the environment step in loginAs The first-run setup helper clicked "Initialize console" and immediately waited for the dashboard, but setup now shows an environment-preflight step before landing the console. Click "Enter Sencho" to complete onboarding before asserting the dashboard, so the first test on a fresh instance passes. |
||
|
|
5e6e96e405 |
feat(mesh): add developer-mode diagnostics to the mesh data plane (#1287)
* feat(mesh): add developer-mode diagnostics to the mesh data plane Add a developer-mode-gated [Mesh:diag] diagnostic log to MeshService, following the existing diagnostic-logging pattern: gated on the shared developer_mode setting (off in production by default) and every value run through sanitizeForLog. The diagnostics cover the forwarder dispatch decision (which self-node resolution won, and whether the route is same-node or cross-node), the entry of the opt-in, opt-out, enable, and disable operations, and completion timing for opt-in, node disable, and the alias-cache refresh. This lets an operator trace a slow or stuck mesh operation from the logs without attaching a debugger. The calls sit only at per-operation, per-accept, and per-refresh cadences, never inside the per-frame byte-relay loops. Add tests covering the developer_mode on/off gating and a guard that a node's api token never reaches a diagnostic log or the activity buffer. * refactor(mesh): redact secret-shaped values in developer-mode diagnostics Run each [Mesh:diag] detail through redactSensitiveText before sanitizeForLog so a future caller cannot leak a Bearer token, JWT, or credentialed URL through a diagnostic line, regardless of which call site emits it. No current call site passes a secret; this is defense in depth. Use a JWT-shaped canary in the no-leak test so it guards the real credential class. |
||
|
|
c6d1631afe |
feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI Add a read-only Recovery tab under Settings (admin-only) backed by a new GET /api/diagnostics endpoint reporting app version, database integrity, encryption-key status, Docker reachability, account and SSO counts, and non-secret configuration. The endpoint loads without Docker or live metrics so it stays available when the dashboard does not, requires a genuine admin session, and builds its config block from a non-secret allowlist so no credentials are ever exposed. Expand the emergency command-line toolkit beyond the two-factor reset with seven host-level commands: reset-password, create-emergency-admin, clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each prints its result, exits with a meaningful status code, and writes an audit entry where it changes state. Document the toolkit in a new operator guide and link it from the recovery and two-factor pages. * feat(recovery): download the emergency command reference as a text file The recovery commands are needed exactly when the dashboard is unreachable, so reading them only in-app is a chicken-and-egg problem. Add a Download button to the command-line section that saves the full `docker compose exec sencho ...` reference as a text file, letting operators keep it on hand before they need it. Reuses a shared download helper with the existing diagnostics export. * fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases Address findings from an independent review of the recovery toolkit: - DiagnosticsService now degrades instead of throwing when a queried table is missing or corrupt: each read falls back and is folded into database.ok, so a broken database reports "problem detected" rather than failing the whole endpoint or showing a misleading healthy state with zeroed counts. - backup-data refuses a destination that resolves to the live database, which would otherwise report success while producing no separate copy. - create-emergency-admin now applies the same username rule as the user- management route, extracted to a shared helper so both stay in sync. Adds tests for a missing read table, a malformed emergency-admin username, and the backup same-target rejection. |
||
|
|
06b25262cc |
feat(stacks): guided first stack import flow (#1285)
* feat(stacks): add guided first stack import flow Add an Import mode to the Create Stack dialog and a zero-stacks empty state so a new user who already has compose files on disk can land their first stack without reading the docs first. A read-only scan of the compose directory (GET /api/stacks/import/scan) lists the compose files it finds with a dry preview of each file's services, ports, volumes, and env files. Each result is labelled by placement: already a stack, loose at the root of the compose directory, or one folder too deep, with the exact path to move misplaced files to. The scan never writes, moves, or changes any files. Manual stack creation (Empty, From Git, From Docker Run) is unchanged. * fix(stacks): read import-scan candidates via a single file handle Open the compose file once and stat plus read on the same descriptor so the size check and the read observe the same inode, instead of resolving the path twice (stat then readFile), which is a time-of-check/time-of-use race. Mirrors the existing handle-based readers in FileSystemService. * fix(stacks): confine import scan to the compose dir and refine the empty state Harden the read-only import scan: - Resolve symlinks and confirm the real target stays inside the compose directory before reading a candidate, and reject non-regular files, so a symlinked compose file or parent cannot expose a file outside the compose directory through the preview (matches resolveSafeStackPath). - Read at most the stat-reported size (bounded by the 1 MiB cap) from the open handle, so a file that grows after the size check cannot exceed the cap. - Log when the compose directory or a subdirectory cannot be read, so an access failure is not silently reported as "no compose files found". Only show the first-run "No stacks yet" prompt when no filter chip is active, so a filter that matches nothing is not mistaken for an empty fleet. |
||
|
|
9fb4ccccff |
fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)
* fix(fleet-secrets): restrict bundle management to admin hub sessions Fleet Secrets exposes decrypted environment-variable values and writes credentials across the fleet, so every route now requires an admin role, runs only on the instance you are signed into, and rejects long-lived API tokens: - Add an admin-role check to all secrets routes; the frontend Secrets tab renders only for admin users so the affordance matches the backend gate. - Add /api/secrets/ to the hub-only path list so a request carrying a remote node id cannot be proxied to read another node's decrypted values. - Reject API tokens on every secrets route (browser admin sessions only), matching how registry credentials are handled. Also adds lifecycle and developer-mode diagnostic logging (never the secret values) and tests covering the admin boundary on every endpoint, API-token rejection, hub-only enforcement, and diagnostic gating. * fix(fleet-secrets): require a signed-in user session for all secrets routes The earlier API-token rejection only blocked opaque API tokens. node_proxy and pilot_tunnel JWTs are mapped to an admin role by the auth middleware without an API-token scope, so they still passed the admin gate and could read decrypted bundles via GET /api/secrets/:id. Replace the API-token check with requireUserSession, which rejects API tokens and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets route, returning SESSION_REQUIRED. The admin role is still enforced after. Tests now assert SESSION_REQUIRED for a full-admin API token across all nine routes and for node_proxy and pilot_tunnel JWTs. * test(fleet-secrets): mint the rejection-test token via the real endpoint The machine-credential test reconstructed an API token by sha256-hashing a raw key inline. That duplicated a hashing sink that CodeQL's js/insufficient-password-hash query flags (a false positive for a 256-bit random token, but a new occurrence in the diff). Create the token through POST /api/api-tokens instead, so the hashing stays in the production path and the test carries none of its own. Behavior and coverage are unchanged. |
||
|
|
c11a550b6a |
fix(fleet-snapshots): gate reads on admin role and encrypt content at rest (#1273)
* fix(fleet-snapshots): gate reads on admin role and encrypt content at rest Fleet snapshots capture every node's compose.yaml and .env, so the data is as sensitive as the live stacks. This hardens access and reliability across the snapshot pipeline. - Restrict snapshot reads to administrators. GET /api/fleet/snapshots and /:id now require the admin role, matching create, restore, and delete; the Fleet "Snapshots" tab and its panel render only for admins. Previously any authenticated user could enumerate snapshots and read every node's .env. - Encrypt snapshot file contents at rest with the instance key. Restore and cloud-archive paths decrypt on read, so cloud archives stay portable and a database copy no longer exposes stack secrets in plaintext. Rows written before this change still read back as plaintext. - Surface partial captures. A stack whose compose file cannot be read or fetched, or a file over the 1 MB capture cap, is recorded as a warning and shown on the snapshot instead of being silently dropped, so a snapshot is never mistaken for complete. Remote .env read errors are now distinguished from a genuinely absent .env. Adds route-authz, capture-warning, and encryption round-trip tests; updates the Fleet-Wide Backups feature docs. * fix(fleet-snapshots): gate cloud snapshot reads on admin role The cloud snapshot read routes were guarded by provider/license only, not by role, while their write counterparts (upload, delete) already required admin and the Cloud Backup settings surface is admin-only. Because a downloaded archive contains plaintext compose and .env files, a non-admin could list and download cloud snapshots and read every node's secrets, the same exposure the local snapshot reads were just closed against. - Require admin on GET /api/cloud-backup/snapshots, /status/:id, and /object/:keyB64/download, matching the local snapshot reads and the admin-only Cloud Backup settings section. - When capturing a remote node, treat a 200 response carrying X-Env-Exists: false as a stack with no .env (matching the local ENOENT path) instead of storing an empty .env that restore would later write back. Adds non-admin authorization tests for the cloud read routes and a remote absent-.env capture test. |
||
|
|
0953025036 |
fix(fleet): gate node update actions to admins and harden update tracking (#1272)
* fix(fleet): gate node update actions to admins and harden update tracking Node update affordances now render only for admins, matching the admin-only routes behind them. Previously a non-admin could open the Fleet view and see the per-node Update button, Update all, retry, dismiss, and Recheck controls, then get a 403 on click. Those controls are now hidden for non-admins, who still see read-only update status. Both update-status clear routes (per-node and bulk) now require admin, and the bulk recheck throttles its forced "latest published version" lookup so a caller cannot loop it to hammer the upstream registries; the response reports whether the refresh actually ran so the UI can surface a "checked recently" note. Completion detection no longer reports a node as Updated when it merely blips offline and returns on the same version with an unchanged process start time. That case stays in progress and is decided by the existing early-fail and timeout heuristics, so a momentary network glitch is not mistaken for a successful update. Failed and timed-out updates now emit an operator-visible warning, and a periodic safety-net sweep bounds in-flight trackers when no client is polling for status. * fix(fleet): harden update completion and recheck failure handling Refinements from review of the node self-update hardening: - Completion signal 1 now requires a valid version, not merely a different one. A node whose /api/meta momentarily omits or mangles its version (online, same process) reported version=null, which compared unequal to the previous version and falsely marked the update completed. It now stays in progress and is decided by the early-fail/timeout heuristics. - Terminal resolution is atomic: it re-reads the live tracker and transitions only if it is still in flight with the same start time, so two concurrent status polls cannot both warn or clobber each other's transition. - The operator warning for a failed or timed-out update now redacts secret-shaped text (bearer/basic/token/password, credentialed URLs) from the underlying error before logging, in addition to stripping control characters. - The Recheck button now surfaces an error toast when the request throws (network or auth failure), matching the existing non-ok-response path instead of only logging to the console. |
||
|
|
7d7e0a6264 |
feat(fleet-sync): gate sync-status polling on admin role (#1271)
* feat(fleet-sync): gate sync-status polling on admin role The fleet sync status poll requires a paid admin on the server, but the useFleetSyncStatus hook only checked the paid tier. A paid non-admin who opened the Fleet Status tab or Settings, Nodes would poll the endpoint every 30 seconds and get a 403 each time, with the policy sync rows silently never loading. Gate the hook on both paid tier and admin role so the client mirrors the server and the request only fires when it can succeed. Also add a one-time log when an instance transitions from control to replica, and a real-database integration test covering the full receive path: role flip, identity and watermark persistence, wholesale row replacement with local rows preserved, replica read-only enforcement, and the stale-watermark and control-anchor rejection branches. Includes a hook test asserting the gate across the paid and admin matrix. * fix(fleet-sync): drop sync-status rows that resolve after the gate flips A sync-status fetch started while the user was an eligible paid admin could resolve after the user lost eligibility mid-flight (role or tier change), re-populating the status rows that the gate-false path had already cleared. That briefly re-enabled the policy-sync rows and the anchor-mismatch banner for a now-ineligible client. Track the latest gate state in a ref and skip the state writes when the fetch resolves after eligibility was lost, so the rows stay empty under role and tier transitions. Adds a regression test for the late-resolve path. * fix(security): sanitize control identity before logging replica transition The control to replica transition log interpolated the control fingerprint straight from the inbound sync payload, so a sibling pushing a forged controlIdentity with embedded CR/LF could split or spoof log lines. Wrap the value in the shared sanitizeForLog helper, matching every other tainted log sink, so control characters are stripped before the entry is written. |
||
|
|
085267b466 |
feat(security): make CVE suppressions optionally honored by deploy-block policies (#1269)
* feat(security): make CVE suppressions optionally honored by deploy-block policies
Block-on-deploy policies evaluate the raw scan result, so a CVE an admin
has accepted in CVE Suppressions still blocks the deploy. Add an opt-in,
per-instance toggle ("Honor suppressions in deploy blocks", Settings ->
Security) that, when on, re-derives each image's severity from the
suppression-filtered findings before comparing to the policy threshold. A
deploy that proceeds only because suppressions dropped it below the gate is
recorded in the audit log. Default off, so the strict raw-scan behavior is
unchanged unless an operator enables it.
The setting governs the instance that runs the deploy and is not
fleet-replicated. The gate fails safe: a suppression-read error or an
empty detail set falls back to raw scan severity rather than dropping it.
Also surface a previously swallowed error in the CVE suppressions and
misconfig acknowledgement settings panels so a failed list load shows a
toast instead of an empty list.
* fix(security): gate on raw severity when preflight detail rows are truncated
The suppression-aware deploy gate re-derived image severity from the stored
vulnerability_details rows, assuming any non-empty set was complete. A cached
pre-deploy scan keeps the full aggregate counts but copies only a bounded slice
of detail rows, so recomputing from that slice could drop an unsuppressed
blocking CVE below the threshold and let a deploy through.
Guard the recompute: when the loaded detail rows do not match the scan's total
finding count, gate on the raw scan severity (never drops severity). Suppression
awareness still applies for scans whose details are stored in full, which is the
common case.
|
||
|
|
b61388c675 |
fix(rbac): enforce admin seat cap on promotion and harden last-admin and audit paths (#1266)
Promoting a user to admin now respects the per-tier admin seat limit the same way user creation does, closing a path that let an operator exceed the cap by creating an account and then editing its role to admin. The last-admin guard for demote and delete now runs the admin-count re-check and the write in a single transaction, so two concurrent admin changes can no longer race the admin count to zero and lock everyone out. Admin two-factor resets are now recorded once with their own audit summary instead of being mislabeled as a user creation by the audit middleware. |
||
|
|
d4fa4a4965 |
fix(host-console): audit session lifecycle and harden path, resize, and route gating (#1263)
* fix(host-console): audit session lifecycle and harden path, resize, and route gating Record an audit-log entry when a host console session opens and when it closes (capturing user, node, client IP, and timestamp), so interactive host-shell access leaves a durable, accountable trail instead of only an ephemeral log line. Fix a stack-path boundary check that allowed a sibling directory sharing the base path's prefix to pass; directory resolution now uses the canonical within-base check via a small testable helper. Validate terminal resize frames (positive integers within a sane bound) before forwarding them to the PTY, dropping malformed frames instead of passing them through. Mirror the backend admin-only console permission on the frontend route so a non-admin who reaches the view cannot mount a console the server would reject, and log (rather than silently swallow) a working-directory resolution failure. Add unit coverage for the path helper, audit open/close rows, resize validation, and the spawn-error path, plus a WebSocket-upgrade integration test exercising the full gate chain and a live session-open audit row. * fix(host-console): record the node the shell actually runs in When the requested node's directory cannot be resolved, the session falls back to the default node's base directory. Record that fallback node in the session audit row (and log it) so the audit trail names the node the shell actually runs in rather than the originally requested one. Strengthen the upgrade integration test to assert the open row captures the user, node, and client IP. |
||
|
|
7e65a2ae19 |
fix(mfa): enforce single-use backup codes under concurrent verification (#1262)
* fix(mfa): enforce single-use backup codes under concurrent verification Backup-code consumption read the stored hash set, awaited bcrypt.compare, then wrote the shrunk set back. Two concurrent /login/mfa requests carrying the same code could both read the same set, both match, and both persist, so a single backup code yielded two authenticated sessions. Move consumption into a synchronous transaction: verifyBackupCode now returns the matched hash without mutating state, and a new consumeBackupCodeHash re-reads and shrinks the set atomically, returning whether the hash was still present. Login gates success on that result, so exactly one concurrent request wins. Reshape the verify result into a discriminated union so a match always carries its hash. Add coverage: a deterministic consume test (same hash twice, distinct hashes, absent hash), a concurrent same-code login race, backup-code exhaustion, and a disable-via-backup-code path. * test(mfa): make the concurrent backup-code test deterministic The race test relied on incidental scheduling to interleave the two requests, so it could false-green if they happened to serialize. Add a barrier that holds both requests just after verification (once both have read the same stored set) until both arrive, then releases them into the atomic consume. This forces the race every run, so the test fails against a non-atomic consume and passes only when exactly one request wins. A timeout releases the barrier if only one request arrives, so a setup fault fails loudly instead of hanging. |
||
|
|
d03d97d964 |
fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility Vulnerability scanning is now gated correctly on whether the active node advertises support for it: - A node without the Trivy binary stops advertising the scanning capability. Previously the capability was toggled only on a state change, so a node that booted without Trivy kept advertising scanning it could not perform. - The control node's own capability list now reflects features disabled at runtime, matching what it advertises to peers. - The scan history surface shows a clear "not available on this node" card, with its header actions hidden, instead of attempting a request that fails. A node's version and capability metadata now refreshes immediately after a connection test or a completed update, rather than waiting out the cache. Capability gates fail closed to the unavailable card when a node's metadata request errors, instead of staying open until the next fetch. Adds a test that fails if the frontend and backend capability lists drift, plus coverage for the metadata error path, the runtime-disabled local meta, the scanning capability sync, and the metadata cache invalidation paths. * fix(nodes): refresh node metadata client-side after a connection test A connection test dropped the server-side metadata cache, but the dashboard kept its own cached copy until the client TTL expired, so version and capability gates could stay stale in the browser. The test now forces a client-side metadata refresh for that node, so the version pill and gates reflect the node's current state immediately. Also strips any URL userinfo before logging the metadata fetch target, and makes the scanning-capability detection test deterministically exercise the no-binary disable path rather than depending on whether the runner has Trivy. |
||
|
|
6fc7f200a6 |
fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility (#1260)
* fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility Stack lifecycle schedules (Restart, Stop, Take Down, Start, Backup Stack Files) now run against whichever node the schedule targets, local or remote. Each remote run proxies to that node's own stack-operation endpoint, so a hub-managed schedule reaches the node that actually holds the stack. Restart with a service subset restarts each selected service and, if one fails, names the services already restarted so run history reflects the stack's partial state. Auto-start on a remote node runs that node's own pre-deploy scan-policy check against the images it holds. Add POST /api/stacks/:name/backup to trigger an on-demand backup of a stack's compose and env files (the same rollback snapshot a deploy takes); it backs the remote backup schedule and is available to operators on its own. A scheduled task that reaches execution on an unpaid licence is now skipped and written to run history as a failed run, so a manual trigger that returned a queued response never silently disappears. Test plan: - Backend unit + integration: scheduler-service (remote proxy per action, per-service fan-out, auto-start policy delegation, remote-failure and no-credentials paths, unpaid-tier skip), stack-backup-route (auth/role/paid/404/400/500), scheduled-tasks-routes. - Frontend component test for the schedules view (list, prefill, node filter, create payload). - tsc and lint clean on both packages. * fix(scheduled-ops): lock the stack-files backup route against concurrent stack ops The stack-files backup writes the same slot the pre-deploy rollback snapshot uses, so running it while a deploy, update, or rollback is in flight on the same stack could overwrite the rollback point. The backup route now takes the per-stack operation lock (as deploy/down/restart do) and returns 409 when the stack is busy, keeping the rollback snapshot intact. Adds the 'backup' action to the stack-op lock type and a busy participle for the 409 message. * fix(scheduled-ops): enforce backup-path containment inline at the filesystem sink The on-demand backup route passes the stack name straight into backupStackFiles, so resolve the backup directory against the backup root and confirm containment with an inline startsWith check before the mkdir/copy/write sinks, matching the barrier restoreStackFiles already uses. The stack name is validated at the route and again by resolveStackDir, so this is defense in depth that also closes a static path-injection finding on the new call path. |
||
|
|
5e66b54153 |
fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history (#1259)
* fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history Harden the Admiral audit log without changing its tier or hub-only gating. - CSV export now defuses formula injection: any field that a spreadsheet would evaluate as a formula (leading = + - @, or a trigger behind leading whitespace, or a leading tab/CR) is prefixed with a single quote before RFC 4180 quoting. Audit summaries embed user-controlled resource names, so this closes a path where a crafted name could execute on export open. - Clamp page and limit to positive bounds on the list endpoint so a negative limit can no longer reach SQLite as "unlimited" and dump the whole table. - Bound the anomaly and stats history reads to a capped slice of recent rows so the analysis paths stay within fixed memory and latency on large histories instead of scanning the full retention window per request. - Surface failed audit list and stats fetches through the standard error toast instead of leaving the view silently stale. - Add a developer-mode-gated diagnostic log to the stats endpoint for parity with the list and export handlers. Covered by new unit and HTTP-integration tests (CSV neutralization through the real export route, pagination clamps, bounded history, stats endpoint, anomaly annotation) and verified end to end in the browser. * fix(audit-log): compute signal-rail stats with exact SQL aggregates Address review feedback on the earlier history-cap change. The cap was correct for the anomaly baseline but made the stats tiles (events, actors, failure rate, hourly series) silently undercount on a hub with more than the cap's worth of rows in the window, since they were derived from the capped row slice. - Add DatabaseService.getAuditStatsInputs: exact counts via SQL COUNT / COUNT(DISTINCT) / GROUP BY hour, and new-ip detection over the small DISTINCT (user, ip) pair sets. No row cap, so the tiles stay exact at any window size while memory stays bounded. - Reduce computeAuditStats to a pure formatter over those aggregates. - Keep the bounded history read only for the list endpoint's anomaly annotation, where a recent-activity baseline is an acceptable heuristic. - Skip the redundant load-failure toast when a fetch fails with a handled 401, so an expired session does not stack toasts on top of logout. Adds exactness tests for the aggregate counts, distinct-actor handling, and new-ip detection, and strengthens the pagination-clamp tests. * fix(audit-log): exclude future-dated rows and make the new-ip sample deterministic Two small parity fixes on the stats aggregates: upper-bound every current window by `now` so a future-dated row (clock skew or a fixture) cannot inflate the live counts, and order the new-ip pair scan so the sample actor shown in the tile detail is stable. Adds a test asserting a future row is excluded. |
||
|
|
dbb7fe8215 |
feat(auto-heal): restart crashed containers and harden the heal loop (#1258)
* feat(auto-heal): restart crashed containers and harden the heal loop Auto-Heal now restarts containers that crash (non-zero exit) and stay down past the policy threshold, in addition to those that fail their Docker healthcheck. Crash detection reuses the container event classifier so a container that exits cleanly or that an operator stopped is never restarted; only classified crashes set the heal signal. Also hardens the existing loop: - A paid controlling instance refreshes proxied remotes' entitlement on a background interval so a remote node's policies keep evaluating between operator visits instead of lapsing a few minutes after the sheet was last opened. A node that stays unreachable surfaces a warning. - Overlapping policies (all-services plus a service-specific one) restart a given container at most once per evaluation pass, so the hourly cap holds. - A failed restart now counts toward the cooldown and hourly cap, so a broken setup is retried on the cooldown interval rather than every poll. - Diagnostic logging behind developer mode for evaluation, heal decisions, timing, and lease refresh. * docs(auto-heal): document crash healing and refresh troubleshooting Cover the two heal conditions (unhealthy and crashed), note that clean exits and operator stops are never restarted and that crash healing acts on crashes observed while Sencho is running, and update the troubleshooting and tab visibility entries accordingly. * fix(auto-heal): close stale crash-signal race and harden lease refresh A crash signal could outlive the crash it described. The exit classifier is deferred 500ms, so an immediate restart could let it stamp the crash marker after the container was already running, and a later clean or operator-initiated exit did not clear it; the next poll could then restart a container that had exited cleanly. Now a clean or intentional exit always clears the marker, a die that a start has superseded is not stamped, and the die's own time is captured at arrival rather than at the deferred classification so the supersede check is accurate. Also: - Crash state survives the event service's idle-prune window, so crash healing works for any configured threshold rather than only short ones. - An exited or dead container is matched before any health-text parsing, so it can never fall into the healthcheck path. - A remote with no reachable proxy target counts toward the lease-refresh failure warning instead of being silently skipped. |
||
|
|
ca346916c1 |
fix(auto-update): paid-gate execute route and harden image-check watchdog (#1257)
* fix(auto-update): paid-gate execute route and harden image-check watchdog Auto-update execution is a paid capability, but POST /api/auto-update/execute was reachable by any admin regardless of license. Add the paid guard so it matches the rest of the surface (scheduled-task management and fleet refresh). The scheduler dispatch to remote nodes still works because the controlling instance forwards its tier with the request. Restrict GET /api/image-updates/fleet to admins. The single-node status endpoint that drives the sidebar update dot stays open to all roles. Replace the image-check watchdog timer that released the run lock after five minutes. On a healthy but slow scan it let a manual refresh start a second concurrent check, duplicating notifications and racing the status writes. The scan now owns its lock for its full duration, and every Docker socket and filesystem read is bounded so a wedged daemon or mount cannot stall a scan forever. * test(auto-update): assert the debug skip-log branch in the image-check guard The concurrency-guard test covered the warn branch for a trigger arriving past the long-run threshold but never exercised the developer-mode debug skip log. Add a case that enables developer mode and asserts the debug line fires for a mid-scan trigger under the threshold. |
||
|
|
7d4e61625f |
fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs (#1255)
* fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs Make NotificationService.dispatchAlert never reject so the many fire-and-forget callers (monitors, event streams, policy and image-update paths) cannot trigger an unhandledRejection on an unhealthy database. The whole dispatch body now sits inside a guard covering node resolution, the history insert, channel-table reads, and the WebSocket broadcast; a failure logs and drops the notification instead of crashing the process. An inner guard still splits the write-success and write-failure metrics. Also: - Redact webhook URLs in diagnostic logs via a new maskWebhookUrl helper; Discord/Slack/custom webhook URLs embed their token in the path, so only the origin is safe to emit. - Add error logging to four notification-history route handlers that previously swallowed database errors silently before returning 500. - Snapshot the subscriber set before broadcasting so a close/error handler firing mid-send cannot mutate the set during iteration. - Sanitize the admin-supplied route name in dispatch log lines. Adds tests for dispatch crash-safety (write failure, post-write routing failure, broadcast send failure), the success-path write metric, webhook URL masking, and history-route error logging. * fix(notifications): sanitize route name and patterns in create logs Apply sanitizeForLog to the admin-supplied route name and stack patterns in the route-creation log lines, matching the dispatch-site sanitization and closing the remaining log-injection path on this feature. Also extend tests: userinfo-stripping in maskWebhookUrl, subscriber-set snapshot behavior under an unsubscribe-during-send, and error logging on the mark-read, delete-one, and clear-all notification-history handlers. |
||
|
|
69edb0dcbb |
fix(observability): gate global logs to admins, scope to managed containers, harden SSE (#1254)
* fix(observability): gate global logs to admins, scope to managed containers, harden SSE Make the Logs feed an administrator view enforced on both sides (requireAdmin on the /api/logs/global poll and SSE routes; the Logs nav item plus a redirect guard on the frontend), and scope the feed to Sencho-managed containers only via a shared isManagedByComposeDir helper that /stats now reuses. Harden the SSE stream: a stateful frame demuxer that survives chunk boundaries so a Docker frame split across reads is reassembled instead of dropped or garbled; a per-stream error listener so one broken follow stream cannot crash the event loop (it posts a single degraded notice and keeps the others alive); a cap on concurrent follow streams with a truncation notice; a bounded initial tail; and backpressure that pauses the source streams when the client is slow and resumes on drain. Bound the polling snapshot's per-container fan-out with a concurrency limit. Add process-local, in-memory log-stream counters exposed at the admin-only /api/system/log-stream-metrics endpoint (active connections, lines streamed, attach and frame errors). Collapse the view to the local hub and remove the dead remote-node handling. * fix(observability): close remote-proxy bypass of the global-logs admin gate The logs feed's requireAdmin lives in the local route handler, which the remote proxy skips when forwarding a request whose nodeId targets a remote node. A hub user could therefore request /api/logs/global*, /api/logs/global/stream, or /api/system/log-stream-metrics with x-node-id (or ?nodeId= for the SSE transport) pointing at a remote node and have it served as the node-proxy admin on the far side, sidestepping the gate entirely. Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with 403 before the proxy runs, matching the existing protection on audit-log, scheduled-tasks, and notification-routes. Add regression tests covering the collection path, the SSE sub-path (both the x-node-id header and the ?nodeId= query transport), and the stream-metrics endpoint. |
||
|
|
96c5f05cdc |
fix(app-store): harden template deploy, registry fetch, and catalogue refresh (#1250)
* fix(app-store): harden template deploy, registry fetch, and catalogue refresh Serialize generated compose through the YAML emitter so registry-supplied values are escaped correctly instead of interpolated into hand-built lines. Cap the registry response size so an oversized or runaway catalogue cannot exhaust backend memory, and surface the fetch failure to the caller. Reload the catalogue when the active node changes, since the registry is node-scoped. Add developer-mode deploy diagnostics (counts only, no values) and extend the unit tests with YAML round-trip, LinuxServer.io mapping, and size-cap coverage. * fix(app-store): reset node-scoped catalogue state on fetch and bound deploy diagnostics Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case). |
||
|
|
2844f606cd |
fix(git-sources): harden webhook delivery, transport errors, and clone limits (#1249)
* fix(git-sources): harden webhook delivery, transport errors, and clone limits Map webhook-pull outcomes to real HTTP status codes (200 success, 202 debounced, 404 no source, 422 failure) instead of always returning 200, so a Git provider and any monitoring on it can tell when a delivery actually failed. Close a concurrent webhook fan-out gap: the debounce window is now re-checked inside the per-stack lock, so simultaneous deliveries for one push run a single clone instead of one per request. The whole pull/apply critical section runs under a single lock acquisition. Unwrap fetch transport causes (ENOTFOUND, ECONNREFUSED, ECONNRESET, TLS) so a clone failure surfaces an actionable, host-qualified message instead of a bare "fetch failed". Cap how many bytes a single clone may download to protect the host disk; operators can tune it with GITSOURCE_MAX_CLONE_BYTES (default 100 MB). Log webhook pull failures server-side, since the webhook path is unattended. * test(git-sources): assert surfaced host via toContain to satisfy CodeQL * fix(git-sources): bound per-file read, treat debounced webhooks as non-failure, correct clone-cap docs * docs(git-sources): correct clone-cap comment to describe a download bound, not disk |
||
|
|
b33a0e8422 |
fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys (#1248)
* fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys A blocked deploy only opened the policy dialog from the editor deploy button. The update action and the sidebar context-menu deploy/update fell through to a generic error toast, so an admin could not review the violations or bypass the block from those entry points. Route the 409 policy response through a shared handler on all three paths and make the "Deploy anyway" bypass retry the originating action (deploy or update) so an update bypass still re-pulls images. Also: - Correct the "Block on deploy" policy-editor helper text, which described post-deploy alerting rather than the pre-flight rejection it actually performs. - Dispatch the documented scan_finding warning (policy name and the offending images) when a scheduled auto-update or auto-start is blocked, instead of recording an opaque failure. - Add a standard log line when the gate blocks a deploy, plus developer-mode diagnostics for the matched policy and per-image severity decision. - Fix deploy-enforcement docs: complete the enforced entry-point list, correct the policy-precedence wording, and remove inaccurate tier and audit-actor claims. * fix(deploy-enforcement): surface policy block on rollback and name images in remote auto-update alert Addresses two gaps found in independent review: - Rollback is a policy-gated deploy path (it restores the saved files then re-runs the gate before redeploying), but the frontend treated a blocked rollback as a generic error toast. Route the 409 through the same handler as deploy and update so the block dialog opens, and let an admin "Deploy anyway" retry the rollback with the bypass flag (the rollback route already honors it). - The remote auto-update path dispatched its policy-block warning without the offending image refs, unlike the local scheduler. Append the images so the alert matches the documented contract on every node. Also list rollback as an enforced entry point in the docs and clarify that Git Source enforcement covers both the create-time deploy and a manual apply-with-deploy. |
||
|
|
45844b92ca |
fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating (#1247)
* fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating Hardens the Atomic Deployments feature found during a full audit: - Rollback now holds the per-stack lifecycle lock (deploy/update already do), so a rollback can no longer race a concurrent deploy on the same compose files. Adds a 'rollback' lifecycle action and releases the lock in finally. - restoreStackFiles is now a faithful revert: it removes managed compose/.env files added after the backup before copying, so a rollback no longer leaves a hybrid of old and new configuration. Scope is the protected file set only; user data is untouched. Aborts (rather than reporting success) if a stale managed file cannot be removed. - The scheduled image-update path derives the atomic flag from the licence tier instead of hardcoding it on, keeping the paid capability explicit at the call site (the scheduler is already paid-gated; this prevents silent drift). - The backup-metadata read (GET /stacks/:name/backup) now requires a paid licence, matching the rollback flow that is the only caller. - Manual rollback dispatches a success/failure notification, alongside the existing audit-log entry. Adds route integration tests (lock acquisition/release, tier 403, notifications, no-backup 404), filesystem tests for the faithful restore (orphan removal, variant switch, abort path, non-managed files preserved), a community-tier scheduler test, and a developer-mode logging matrix. Documents the restore semantics and reconciles the scheduled-update wording in the feature guide. * fix(atomic-deploy): assert restore target stays within the compose dir before unlink The orphan-removal step in restoreStackFiles joins the stack directory with a managed filename and unlinks it. The stack directory is already validated and contained by resolveStackDir (allowlist stack name + within-base assertion), but the containment guard was not reapplied to the joined target at the delete sink, so static analysis flagged the path as derived from user input. Reassert containment on the final path before unlinking, matching the barrier the other write/read helpers in this service already apply. No behavior change for valid stacks; defense-in-depth at the sink. * fix(atomic-deploy): inline the path-containment barrier at the restore unlink sink The wrapped within-base assertion was not recognized as a sanitizer by the static path-injection analysis, which still traced the stack name to the unlink sink. Replace it with the inline path.resolve + startsWith containment check the other write helpers in this service already use (the recognized barrier), kept in the same scope as the sink. Behavior is unchanged for valid stack names. * fix(atomic-deploy): clear stale managed files from the backup slot before writing The backup directory is reused across runs and was only ever added to, never cleared. A managed file removed from the stack since the last backup (e.g. a deleted .env or a switched compose variant) lingered in the slot, so a later rollback restored a file that did not exist immediately before the failed run, contradicting the faithful-revert guarantee. Clear the protected file set from the slot before copying the current files, with the same inline containment barrier the restore path uses. A clear failure is logged, not fatal, since it only risks a stale future rollback and should not block a valid deploy. |
||
|
|
5dea040ec8 |
fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream The deploy progress modal streamed compose output over a WebSocket, but the deploy itself was coupled to that socket in two ways that could break or silently abort a deploy: - The deploy request was gated on the progress socket connecting, so any upgrade failure (a reverse proxy blocking WebSocket upgrades, or the admin-only stream rejecting a scoped deployer) left the modal stuck on "Connecting..." and the deploy never fired. - The backend terminated the running compose process when that socket closed, so minimizing the modal, navigating away, or a network blip aborted an in-flight deploy. Make the progress socket output-only: the deploy is owned by its request and runs to completion (or the existing command timeout) regardless of the stream. The modal now degrades to a "Live progress unavailable" state and still reports success or failure from the request result. Connect failures, drops, and a connect timeout all release the deploy instead of blocking it. Also route progress output per deploy: the frontend sends a correlation id on both the connectTerminal message and the deploy request header, and the backend keys progress sockets by that id so concurrent deploys from different tabs or users no longer cross-stream each other's output. Cap the in-memory parsed log rows so a very long deploy cannot grow the modal's state unbounded. * fix(deploy-progress): generate the deploy session id with a CSPRNG The per-deploy correlation id keys which WebSocket receives a deploy's live output, so a guessable id lets one authenticated client register a victim's id and read its compose output. It was built from Math.random() plus a timestamp, which is not cryptographically secure. Generate it with crypto.getRandomValues (128 bits, hex). That is the one Crypto member available in insecure contexts, so it still works over LAN HTTP where crypto.randomUUID is unavailable. * fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal Address review findings on the progress-stream routing: - Only an id-less connectTerminal registration may become the id-less fallback socket. Previously every connectTerminal (including keyed deploy modals) set the fallback, so a headerless operation (bulk update, rollback, or a legacy client) resolved via getTerminalWs() into another user's keyed deploy modal. Keyed sockets are now excluded from the fallback, and a socket that adopts a session id is removed from it. - The connect-timeout fallback now also flags the modal as "Live progress unavailable" instead of leaving it on "Connecting..." while the deploy runs. - Log only a short prefix of the deploy session id in developer diagnostics, not the full capability value. |
||
|
|
265fece988 |
fix(notifications): prevent self-container stack routing (#1242)
* fix(notifications): prevent self-container stack routing * fix(stack-files): stabilize download metrics in CI |
||
|
|
adcd04b01a |
refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only
The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.
The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.
Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
accessors, the three routes under /api/stacks/*/auto-update, the per-stack
skip in /api/auto-update/execute and SchedulerService.executeUpdate's
fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
optimistic toggle plumbing. Drop autoUpdateSettings state, the
/stacks/auto-update-settings fetch, and the auto-update-settings-changed
WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
now means "a covering enabled action='update' schedule exists" (per-stack
row or fleet row on this node, earliest next_run_at wins, per-stack row
wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
for the 60s poll. The broadcast is wrapped so a broken subscriber socket
cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
to describe the schedule-based model; update the matching troubleshooting
entry. The misleading fleet-update help text in ScheduledOperationsView
is corrected to reflect that every stack on the node is covered.
Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.
No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.
* docs: sweep remaining references to the per-stack auto-update toggle
The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:
- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
group description, the matching screenshot alt-text, and the Skipper
Note that listed it. Schedule task now carries the cross-link to
Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
"Auto-update stacks" to "Auto-update schedules" with the new value
shape, and rewrite the troubleshooting accordion to describe the
scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
Stacks row and helper text to reflect that every stack on the node is
covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
All Stacks schedules instead of the removed per-stack policy.
The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.
No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.
* fix(stacks): drop orphaned requireAdmin import after auto-update route removal
CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.
tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
|
||
|
|
42e8d3a78c |
feat(security): per-image scroll + retention cap in scan history (#1231)
* feat(security): per-image scroll + retention cap in scan history Long scan histories for hot images used to monopolise the Scan history sheet: a single image with dozens of scans pushed every other image off screen, and the underlying vulnerability_scans table grew without bound. Each image group's table now renders inside its own ScrollArea capped at max-h-64 (~6 rows visible) so a busy image scrolls independently while the list of images stays navigable. A new global setting scan_history_per_image_limit (default 50, min 5, max 1000) backs both a window-function query that caps the response per image_ref and a prune step that runs on the existing MonitorService cleanup tick. The response now carries cappedImageRefs + perImageLimit so the UI can render a "Capped at N · older scans pruned" hint on groups sitting at the ceiling without a second settings round-trip. Single-image deep-dive (imageRef query param) bypasses the cap so a user clicking into one image can still see its full history. The prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER issues on first-run installs with large backlogs, and explicitly deletes child rows from vulnerability_details, secret_findings, and misconfig_findings inside a transaction since FK cascade is not enabled at the connection level. Settings → Developer → Data retention gains a "Scan history per image" field. * fix(security): skip searchDraft debounce on mount to stop page-reset race The searchDraft debounce useEffect fires once on initial mount with the unchanged value and, 300ms later, unconditionally calls setPage(0). When a user (or a test) paginates inside that 300ms window, the pending debounce silently undoes the page advance. CI surfaced this as a flaky 3rd fetch in the "advances offset when the user pages forward" test once the per-image cap work added enough state-update overhead to push the click past the 300ms threshold on the slower Linux jsdom run. Track searchDraft with a ref and exit the effect when the value has not actually changed, so the debounce only runs in response to real user typing. |
||
|
|
80499ee18d |
feat(stack-activity): in-process metrics, structured diagnostic logs, docs (#1229)
Phase 3 + Phase 6 of the Stack Activity audit (PR 2 of 2): - StackActivityMetricsService: in-process counters and ring-buffered latency histogram (1000 samples per nodeId/op pair). Mirrors the FileExplorerMetricsService pattern shipped in #1216. No external export. Records (nodeId, op) where op is read or write, with success/error counts and p50/p95 latency on demand. - Admin endpoint GET /api/stack-activity-metrics returns the snapshot. Admin-only via requireAdmin, mounted next to the file-explorer metrics route. An operator debugging "why is the activity tab slow on this node?" can pull per-(nodeId, op) counts and latencies without scrolling logs. - Diagnostic logs: route handler emits a structured [StackActivity:diag] read entry per request (stackName, nodeId, limit, before, beforeId, returned, elapsedMs); dispatchAlert emits a [StackActivity:diag] write entry per persisted notification (category, stackName, nodeId, actor, messageLen). Both gated on developer_mode via isDebugEnabled. Same namespace so a single grep covers reads and writes on the timeline path. Per-request and per-event, never inside a poll loop. - Metric record points: the route's try/finally records a read metric with the outcome of the DB call; dispatchAlert records a write metric on both the success path and (before re-throwing) the failure path, so error rates from the insert path stay visible. - docs/features/stack-activity.mdx: refreshed to reflect PR 1's retention behavior (30 days plus per-(node, stack) 500-row cap, 1000 per-node unattached), composite (timestamp, id) cursor, error-vs- empty UI distinction, and "by username" vs "via Subsystem" actor rendering. Adds Troubleshooting entries for "Activity unavailable" (node disconnect or fetch failure), "expected event missing" (retention windows), and "same restart shows twice" (manual click vs Auto-Heal redeploy are distinct events). No tier, role, or capability gate touched. The admin metrics endpoint inherits the standard requireAdmin gate already used by /api/file- explorer-metrics and /api/stack-metrics. |
||
|
|
2d56ea958a |
fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)
* fix(stack-activity): per-stack history integrity, attribution, sanitization Address the Stack Activity audit findings (PR 1 of 2): - Per-stack history integrity: drop the per-insert 100-row prune in addNotificationHistory that evicted quieter stacks' history whenever another stack got chatty. Periodic cleanupOldNotifications now caps per (node, stack) at 500 rows and per-node unattached system events at 1000 rows, on top of the existing 30-day retention. Signature takes an options bag and returns a per-stage summary so MonitorService can log what actually ran each cycle. - Actor attribution: thread req.user?.username through every notifyActionFailure call site and add synthetic actors at service emit sites (system:autoheal, system:scheduler, system:image-update, system:docker-events, system:blueprint, system:monitor, system:policy). The timeline renders system actors as "via <Label>" so an autoheal redeploy is no longer indistinguishable from a user redeploy. - Message sanitization: new sanitizeNotificationMessage at NotificationService.dispatchAlert strips KEY=VALUE pairs whose key ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and truncates to 1000 chars. Applied to the stored history and to every downstream Discord/Slack/webhook channel. The ImageUpdateService recovery-path direct DB write also runs through the sanitizer. - Composite pagination cursor: getStackActivity now accepts a (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only form silently dropped events when a single compose up emitted many events sharing one millisecond. Route rejects beforeId without before. - Frontend hardening: distinct error state with retry button (initial fetch failure no longer renders as the genuine empty state), strict positive-integer parsing on cursor params, overrequest-by-1 pagination so the last page does not leave a dead "Load more" click, runtime guard on liveEvents merge that validates the level union, per-minute day-bucket recompute so an open panel does not stay on "Today" past midnight. No tier, role, or capability gate touched. Route permission gate remains stack:read on the named stack. * fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir External review surfaced two leak paths in the message sanitizer: - The sensitive-key regex was uppercase-only. Compose env names are conventionally uppercase but lowercase forms (db_password, jwt_secret, github_token) are valid and do leak through the same Docker and compose-parse error paths. Make the regex case-insensitive and tighten it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word, while still leaving BYPASS, COMPASS, and similar non-secret keys alone. - The compose-dir path collapse only read process.env.COMPOSE_DIR, but the real resolution chain is node.compose_dir (per-node DB override) -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom compose_dir could still leak absolute paths into stored history and downstream channels. Route both the dispatchAlert call and the ImageUpdateService recovery-path direct write through NodeRegistry.getInstance().getComposeDir(localNodeId) so the collapse covers every resolution outcome. Tests now assert lowercase keys are redacted and that BYPASS-style non-secrets stay intact in both cases. notification-routing mock extended to stub the new getComposeDir call. * chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal Close three small follow-ups on the per-stack activity timeline: - A11y: each day-group gets role="list" and each event row gets role="listitem" so screen readers traverse the timeline as a list instead of a wall of text. The day-group container also carries an aria-label naming the bucket. - Visibility-aware day-bucket tick: the 60s setInterval that re-derives Today/Yesterday/Earlier now short-circuits when document.hidden, so a backgrounded panel does not re-render every minute for no visible effect. - Live-disconnect signal: useNotifications dispatches a sencho:notifications-connection custom event on WebSocket open and close. The timeline listens and, when explicitly disconnected, shows a one-line "Live updates offline; reconnecting…" hint above the list. The sidebar ticker already surfaces fleet-wide connection state; this adds an in-context cue for users who are focused on a single stack. Stack-name case normalization was considered and rejected: stack names are case-permissive per the isValidStackName validator, and lowercasing on read or write would silently rename or hide a user's "MyApp" stack. * ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex ESLint no-useless-escape errored on \- inside the character class [a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the end of the class so it's an unambiguous literal and the escape is no longer required. Behavior is identical; sanitizer tests still pass. * revert(stack-activity): drop unvalidated E2E spec from this PR The spec was committed without ever running against a real Docker daemon, then failed in CI when it ran for the first time: deploy returned 200 but no notification appeared on the activity endpoint within the polling window, suggesting either a deploy-notification race or a node-id resolution mismatch in the CI environment. Backend unit tests (route + composite cursor + sanitizer) and frontend component tests cover the same logic. The E2E spec will land in a dedicated follow-up once it has been authored against a working CI environment. |
||
|
|
9f2f13f35a |
feat(stack-files): in-process metrics and structured mutation logs (#1216)
* feat(stack-files): in-process metrics and structured mutation logs
Adds FileExplorerMetricsService, an in-memory counter and latency
histogram keyed by (nodeId, op) modelled on StackOpMetricsService.
record() is called once per file-route request from a small
recordFileOp helper that wraps the metric capture; rejection paths
that ran real filesystem work (overwrite confirms, write conflicts,
multer oversize) now record an error count and a warn log instead of
disappearing from the snapshot. recordUploadBytes tracks bytes that
actually persisted so a node taking many small uploads vs a few large
ones is visible in the dashboard.
Admin-only GET /api/file-explorer-metrics returns the snapshot in the
same shape as /api/stack-metrics so an operator chasing a slow node
has a single place to look. No external telemetry; everything is
process-local and resets on restart.
Mutation INFO lines now carry op, stack, path, and bytes/mode/
recursive/overwrite/toPath in the structured details so log scrapers
can pivot on the same identity the metric uses. The existing
developer_mode gate on logFileDiag is unchanged. A new
rejectFileMutation helper centralises the log+metric+response triple
on the three rejection sites (upload DIR_EXISTS, upload FILE_EXISTS,
write PRECONDITION_FAILED) so a future rejection cannot skip the
metric.
Tests cover the service in isolation (counts, p50/p95, ring buffer cap,
upload bytes tracking, snapshot sorting), the admin route auth and
shape, and the route layer end-to-end: a real upload surfaces in
/api/file-explorer-metrics, a FILE_EXISTS rejection bumps errorCount,
and the structured INFO line carries the expected fields.
* fix(stack-files): tighten download/upload latency tracking
Two metric-accuracy bugs caught in independent review:
Download metric was recorded as a success before result.stream.pipe(res)
ran. A mid-stream read failure or a client disconnect was not counted as
an error because the recorder fired at pipe time, not stream completion.
Now the recorder hangs off res.on('finish') for success and on both the
stream's error event and res.on('close') for failure, with a flag so a
normal completion (which emits both finish and close) does not produce
two recordings.
Upload latency was inconsistent across the success and failure branches.
The multer wrapper captured startedAt at route entry, but the async
handler created its own startedAt after multer had already buffered the
body. Successful uploads therefore reported only the post-multer time
and the multipart transfer/buffer cost vanished from the histogram. The
wrapper now stashes the route-entry timestamp on the request object and
the async handler reads it back, so every metric for a given upload
shares one window.
A new test pins the download recorder behaviour: a single successful
download must produce successCount=1, count=1, errorCount=0 in the
snapshot, which would have flagged the original double-fire path the
finish+close pair could have introduced.
|
||
|
|
d8b6f8cf3b |
feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files
The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.
readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.
SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.
Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.
* fix(stack-files): guard the binary-override path against oversized files
The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.
Two reinforcing changes:
- Initial load now checks result.oversized before result.binary, so a
file that is both oversized and has binary bytes in the 8 KB probe
shows the Download panel rather than the binary panel. The size
signal stays in front of the operator and the override button never
surfaces for a file that cannot be safely opened inline.
- The handleForceText handler now respects result.oversized on the
refetch and transitions to the Download panel instead of clearing
isBinary and copying result.content ?? '' into Monaco.
Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.
Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
|
||
|
|
c2357ec534 |
fix(stack-files): symlink-aware delete and chmod (#1214)
deleteStackPath now lstats the leaf and unlinks the link entry itself when it is a symbolic link, so the file the user clicked on in the tree is what gets removed (the linked target stays intact). chmodStackPath rejects with LINK_CHMOD_UNSUPPORTED on a symlink rather than silently mutating the target's permissions; Node's lchmod is macOS-only and following the link is the bug being fixed here. Path-component symlinks are still resolved via the existing resolveSafeStackPath, so a symlinked parent that escapes the stack dir still surfaces SYMLINK_ESCAPE before the leaf is inspected. Service-level tests cover delete on internal-target / external-target / broken / dir-target symlinks, chmod rejection on symlinks (including the broken case), and non-symlink regression checks. Route-level tests pin the 409 LINK_CHMOD_UNSUPPORTED mapping and the link-only-delete behaviour. The describe blocks are platform-gated; Windows symlink creation needs admin/developer-mode and is skipped along with the existing SYMLINK_ESCAPE test. Docs updated to describe both behaviours in plain product terms. |
||
|
|
fcf2222604 |
feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* feat(stack-files): cap directory listings at 1000 + add file-tree filter
The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.
The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.
The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.
* fix(stack-files): filter keeps parent dirs when loaded descendants match
The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.
Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.
Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
|
||
|
|
4964320f50 |
fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag PUT /api/stacks/:name/files/content previously did a blind write; two operators editing the same script lost one of the saves with no warning. The compose-file editor already had mtime optimistic concurrency (PR #1183); this brings the file-explorer write path to the same shape. GET /files/content now also returns mtimeMs and sets a weak ETag header derived from the stat. The matching PUT reads If-Match, asks FileSystemService.writeStackFileIfUnchanged to compare against the live mtime, and returns 412 PRECONDITION_FAILED with the current content and mtime when the stale-write check fails. Successful writes echo a fresh ETag so the client can pin the next save without re-GET. readStackFile and writeStackFileIfUnchanged each open the file once and stat+read through the same handle so the mtime returned to the client matches the bytes that were sent, even if the file is replaced between the two operations. PUT without If-Match still succeeds (backward compatibility with scripted clients that do not roundtrip the ETag). FileViewer now sends the loaded mtime on save, updates its local mtime from the success response, and on FileConflictError adopts the server snapshot as the new baseline so the user's follow-up edit-and-save does not loop on the same precondition. * fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict Two follow-ups from code review on the prior commit: - writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is set and the target has been deleted. The caller was editing a file that no longer exists; silently writing the buffer to the void is wrong. The client adopts the empty snapshot as 'file is gone, start over' and the user keeps control of what to save next. - The FileViewer conflict handler no longer overwrites the user's typed buffer with the server snapshot. It updates the baseline so the next save sends the fresh mtime, then leaves the editor content alone. The user sees their edits, the Save button stays enabled, and a follow-up click applies their changes on top of the new server version without silently destroying what they typed. * fix(api): preserve default headers when caller supplies a headers field apiFetch built defaultOptions.headers by merging Content-Type, x-node-id, and the caller's headers, but then spread the unmodified fetchOptions over defaultOptions at the outer level. The spread overwrote the merged headers with the caller's bare headers, silently dropping Content-Type on every request that supplied any custom header. This was latent until the file-explorer save path started sending an If-Match header. The Express body parser refused the PUT without Content-Type, the route returned 400, the editor showed an error toast instead of the success toast, and the Playwright save assertion timed out. Destructure headers out of fetchOptions before the outer spread so the already-merged defaultOptions.headers survives. Add api.test.ts with four regression cases pinning Content-Type, the If-Match merge, x-node-id presence when active, and localOnly skip. |
||
|
|
668eda6cc8 |
fix(stack-files): atomic write via tmp+rename with optional exclusive mode (#1205)
* fix(stack-files): atomic write via tmp+rename with optional exclusive mode writeStackFile and writeStackFileBuffer previously called fs.writeFile directly, which truncates the target then streams the new bytes. A crash, disk-full event, or process kill between the truncate and the write left the target with partial content and no easy way to detect the half-write at read time. A private writeStackFileAtomic helper stages every write into a sibling .sencho-tmp-<suffix> file in the same directory, fsyncs, then promotes via fs.rename. A crash now leaves either the original target intact or a leftover .sencho-tmp file (cleaned up on the next failure path); a torn target file is no longer reachable through this path. The helper accepts an optional `exclusive: true` flag that swaps the final promote step from rename to link+unlink. link is atomic against EEXIST so a caller that needs "create only if not present" gets a race-free FILE_EXISTS error instead of a clobber. The upload route's overwrite-confirm flow (PR #1204) will wire this through in a follow-up so the existence check becomes authoritative. Behaviour for current callers (writeStackFile, writeStackFileBuffer, BlueprintService deploy) is unchanged: the non-exclusive default matches the prior fs.writeFile semantics from the caller's perspective. * fix(stack-files): tighten atomic write entropy + concurrent / failure tests Tmp suffix now uses crypto.randomBytes(6) so the per-process collision window is a true 48-bit space (Math.random().toString(36).slice(2,6) could drop leading zeros and narrow entropy unpredictably). Adds a short comment on the Windows link path noting NTFS / same-FS POSIX is required, both guaranteed by tmp+target being siblings. Two new tests close coverage gaps the first round missed: - a write step that throws (writeFile rejected) leaves no tmp leak and no partial target; - concurrent non-exclusive writers settle with at least one success and the final file is exactly one of the inputs (POSIX silently overwrites; Windows EPERMs the loser, both consistent). |
||
|
|
c8b095b887 |
fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target Same-name uploads previously truncated the existing file silently. A user dragging a file with a name that matched an in-place file destroyed the original with no warning and no undo. The upload route now reads ?overwrite=0|1. When the flag is not set and the target already exists, the server returns 409 FILE_EXISTS and the original file is untouched. The frontend opens a confirm dialog and retries with overwrite=1 on the user's approval; cancel keeps the original. A new pathExists helper on FileSystemService performs the existence check through the same path-resolution barrier as the write so a malicious relPath cannot bypass the conflict check. UploadConflictError is exported so callers can distinguish the conflict case from generic upload failures without parsing error strings. * fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check |
||
|
|
37b12379c1 |
fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files (#1202)
* fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files Previously the per-stack file explorer treated PROTECTED_STACK_FILES (compose.yaml, compose.yml, docker-compose.yaml/.yml, .env) as a UI hint only. A direct API call from any user with stack:edit could delete or rename compose.yaml and break the stack irrecoverably because the next deploy would fail to find a compose file and the write was unrecoverable without a DB backup. The frontend DeleteFileConfirm enforced a type-to-confirm gate but a stale UI or a scripted client bypassed it. FileSystemService now refuses the destructive ops at the service layer with a new PROTECTED_FILE error code that the route layer surfaces as 409. The compose-editor save path (PUT /files/content) and the upload-overwrite path (POST /files/upload writing a same-named file) remain unblocked because both are legitimate ways to update compose.yaml. Tests pin the allowed paths so a future tightening can not silently regress them. The protection is scoped to entries at the stack root; subdirectory files happen to share a protected name (e.g., a snapshot under backups/compose.yaml) are not blocked because compose CLI only reads the root file. A trailing-slash bypass is closed by stripping trailing separators before the basename check. Frontend DeleteFileConfirm needs no change. Its existing toast.error surface renders the friendly server message. * fix(stack-files): drop polynomial regex from protected-file helpers CodeQL js/polynomial-redos flagged the /\/+$/ pattern used to strip trailing slashes from relPath in isProtectedRelPath and protectedFileError. The regex is bounded in practice (the upstream validator rejects '//' anywhere in the path) but the static analyzer cannot follow that dataflow guarantee and would have flagged any future caller that skips the validator. Replace the two callsites with a small stripTrailingSlash helper that uses endsWith + slice. Bounded O(1), no regex, no analyzer alert. The inline comment documents the upstream invariant so a future reader does not reintroduce the /+ quantifier. |