release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

@@ -1,586 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
DATA_DIR,
"ssl",
"cloudflare.ini",
);
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
function getCertInfo(): {
status: "none" | "valid" | "expiring" | "expired";
expiresAt: string | null;
} {
const certFile = path.join(SSL_DIR, "termix.crt");
try {
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
stdio: "pipe",
});
} catch {
return { status: "none", expiresAt: null };
}
try {
const endDateRaw = execFileSync(
"openssl",
["x509", "-in", certFile, "-noout", "-enddate"],
{ stdio: "pipe" },
)
.toString()
.trim()
.replace("notAfter=", "");
const expiresAt = new Date(endDateRaw).toISOString();
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "0", "-noout"],
{
stdio: "pipe",
},
);
} catch {
return { status: "expired", expiresAt };
}
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
{
stdio: "pipe",
},
);
return { status: "valid", expiresAt };
} catch {
return { status: "expiring", expiresAt };
}
} catch {
return { status: "none", expiresAt: null };
}
}
async function getAcmeSettings(): Promise<AcmeSettings> {
const { status, expiresAt } = getCertInfo();
const value =
await createCurrentSettingsRepository().get("acme_ssl_settings");
const stored = value ? JSON.parse(value) : {};
return {
enabled: stored.enabled ?? false,
domain: stored.domain ?? "",
email: stored.email ?? "",
challengeType: stored.challengeType ?? "http-webroot",
cloudflareToken: stored.cloudflareToken
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
: "",
lastIssuedAt: stored.lastIssuedAt ?? null,
certStatus: status,
certExpiresAt: expiresAt,
};
}
export function registerAcmeSSLRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/acme-ssl-settings:
* get:
* summary: Get ACME SSL settings
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
* tags:
* - Users
* responses:
* 200:
* description: ACME SSL settings and certificate status.
* 500:
* description: Failed to get ACME SSL settings.
*/
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to get ACME SSL settings", err);
res.status(500).json({ error: "Failed to get ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-settings:
* patch:
* summary: Update ACME SSL settings (admin only)
* description: Saves ACME/Let's Encrypt configuration.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* domain:
* type: string
* email:
* type: string
* challengeType:
* type: string
* enum: [http-webroot, dns-cloudflare, manual]
* cloudflareToken:
* type: string
* responses:
* 200:
* description: ACME SSL settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update ACME SSL settings.
*/
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const { enabled, domain, email, challengeType, cloudflareToken } =
req.body;
const updated = {
...current,
...(typeof enabled === "boolean" && { enabled }),
...(typeof domain === "string" && { domain }),
...(typeof email === "string" && { email }),
...(typeof challengeType === "string" && { challengeType }),
...(typeof cloudflareToken === "string" &&
cloudflareToken &&
!cloudflareToken.includes("*") && { cloudflareToken }),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_acme_ssl_settings",
resourceType: "setting",
details: JSON.stringify({
enabled,
domain,
email,
challengeType,
hasCloudflareToken: !!updated.cloudflareToken,
}),
ipAddress,
userAgent,
success: true,
});
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to update ACME SSL settings", err);
res.status(500).json({ error: "Failed to update ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-request:
* post:
* summary: Request or renew Let's Encrypt certificate (admin only)
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
* tags:
* - Users
* responses:
* 200:
* description: Certificate issued or renewed successfully.
* 400:
* description: Invalid configuration.
* 403:
* description: Not authorized.
* 500:
* description: Certificate issuance failed.
*/
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsValue =
await createCurrentSettingsRepository().get("acme_ssl_settings");
if (!settingsValue) {
return res.status(400).json({ error: "ACME settings not configured" });
}
const settings = JSON.parse(settingsValue);
const { domain, email, challengeType, cloudflareToken } = settings;
if (!domain || !email) {
return res.status(400).json({ error: "Domain and email are required" });
}
try {
execFileSync("certbot", ["--version"], { stdio: "pipe" });
} catch {
return res
.status(500)
.json({ error: "certbot is not available in this environment" });
}
await fs.mkdir(SSL_DIR, { recursive: true });
await fs.mkdir(ACME_WEBROOT, { recursive: true });
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
const certbotDirArgs = [
"--config-dir",
CERTBOT_CONFIG_DIR,
"--work-dir",
CERTBOT_WORK_DIR,
"--logs-dir",
CERTBOT_LOGS_DIR,
];
let certbotArgs: string[];
if (challengeType === "dns-cloudflare") {
if (!cloudflareToken) {
return res.status(400).json({
error: "Cloudflare API token is required for DNS challenge",
});
}
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
recursive: true,
});
await fs.writeFile(
CLOUDFLARE_CREDENTIALS_FILE,
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
{ mode: 0o600 },
);
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--dns-cloudflare",
"--dns-cloudflare-credentials",
CLOUDFLARE_CREDENTIALS_FILE,
"--dns-cloudflare-propagation-seconds",
"30",
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
} else {
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--webroot",
"-w",
ACME_WEBROOT,
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
}
authLogger.info("Requesting Let's Encrypt certificate", {
domain,
challengeType,
operation: "acme_cert_request",
});
execFileSync("certbot", certbotArgs, {
stdio: "pipe",
timeout: 120000,
});
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
const fullchainSrc = path.join(liveDir, "fullchain.pem");
const privkeySrc = path.join(liveDir, "privkey.pem");
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.copyFile(fullchainSrc, certDest);
await fs.copyFile(privkeySrc, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
await createCurrentSettingsRepository().set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Let's Encrypt certificate issued and installed", {
domain,
operation: "acme_cert_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ domain, challengeType, success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
/**
* @openapi
* /users/manual-ssl-upload:
* post:
* summary: Upload a manual/custom SSL certificate and key (admin only)
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate uploaded and installed successfully.
* 400:
* description: Invalid or missing certificate/key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { certificate, privateKey } = req.body;
if (
typeof certificate !== "string" ||
typeof privateKey !== "string" ||
!certificate.includes("BEGIN CERTIFICATE") ||
!privateKey.includes("PRIVATE KEY")
) {
return res.status(400).json({
error: "A valid PEM certificate and private key are required",
});
}
await fs.mkdir(SSL_DIR, { recursive: true });
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
try {
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
try {
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
stdio: "pipe",
});
execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
{ stdio: "pipe" },
);
} catch {
return res.status(400).json({
error:
"The provided certificate or private key is not valid PEM data",
});
}
const certPubkey = execFileSync(
"openssl",
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
{ stdio: "pipe" },
);
const keyPubkey = execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-pubout"],
{ stdio: "pipe" },
);
if (!certPubkey.equals(keyPubkey)) {
return res
.status(400)
.json({ error: "The certificate and private key do not match" });
}
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.rename(tmpCertFile, certDest);
await fs.rename(tmpKeyFile, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
} finally {
await fs.rm(tmpCertFile, { force: true });
await fs.rm(tmpKeyFile, { force: true });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const updated = {
...current,
challengeType: "manual",
lastIssuedAt: new Date().toISOString(),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Manual SSL certificate installed", {
operation: "manual_ssl_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("Manual SSL certificate upload failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
@@ -1,534 +0,0 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentAlertRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const VALID_TRIGGER_TYPES = new Set([
"host_offline",
"host_online",
"cpu_threshold",
"memory_threshold",
"disk_threshold",
"health_check_failure",
"health_check_recovery",
"user_login",
]);
router.use(authenticateJWT);
// ---- Notification Channels ----
/**
* @openapi
* /notification-channels:
* get:
* summary: List notification channels for the current user
* tags:
* - Alerts
* responses:
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentAlertRepository().listNotificationChannels(userId);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
operation: "list_channels",
error: err,
});
res.status(500).json({ error: "Failed to list channels" });
}
});
/**
* @openapi
* /notification-channels:
* post:
* summary: Create a notification channel
* tags:
* - Alerts
*/
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
type: string;
config: unknown;
enabled?: boolean;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
}
if (type === "ntfy") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "ntfy config requires url" });
if (!c.topic || typeof c.topic !== "string")
return res.status(400).json({ error: "ntfy config requires topic" });
}
if (type === "webhook") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row = await createCurrentAlertRepository().createNotificationChannel({
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
});
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
operation: "create_channel",
error: err,
});
res.status(500).json({ error: "Failed to create channel" });
}
});
/**
* @openapi
* /notification-channels/{id}:
* put:
* summary: Update a notification channel
* tags:
* - Alerts
*/
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const repository = createCurrentAlertRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
* /notification-channels/{id}:
* delete:
* summary: Delete a notification channel
* tags:
* - Alerts
*/
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentAlertRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
* /notification-channels/{id}/test:
* post:
* summary: Send a test notification
* tags:
* - Alerts
*/
router.post(
"/notification-channels/:id/test",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row =
await createCurrentAlertRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
hostName: "Test Host",
hostId: 0,
triggerType: "test",
message: "This is a test notification from Termix",
severity: "info" as const,
timestamp: new Date().toISOString(),
ruleId: 0,
ruleName: "Test",
};
try {
let config: Record<string, unknown>;
try {
config = JSON.parse(row.config) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ success: false, error: "Invalid channel config" });
}
if (row.type === "webhook") {
await sendWebhook(
config as unknown as Parameters<typeof sendWebhook>[0],
testPayload,
);
} else if (row.type === "ntfy") {
await sendNtfy(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
res.json({ success: true });
} catch (err) {
res.json({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
);
// ---- Alert Rules ----
/**
* @openapi
* /alert-rules:
* get:
* summary: List alert rules for the current user
* tags:
* - Alerts
*/
router.get("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
res.json(await createCurrentAlertRepository().listAlertRules(userId));
} catch (err) {
databaseLogger.error("Failed to list alert rules", {
operation: "list_alert_rules",
error: err,
});
res.status(500).json({ error: "Failed to list alert rules" });
}
});
/**
* @openapi
* /alert-rules:
* post:
* summary: Create an alert rule
* tags:
* - Alerts
*/
router.post("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const {
name,
hostId,
enabled,
triggerType,
thresholdValue,
thresholdDurationSeconds,
cooldownMinutes,
channels = [],
} = req.body as {
name: string;
hostId?: number | null;
enabled?: boolean;
triggerType: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
return res.status(400).json({ error: "Invalid triggerType" });
}
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
return res
.status(400)
.json({ error: "thresholdValue must be between 0 and 100" });
}
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
return res
.status(400)
.json({ error: "thresholdDurationSeconds must be >= 0" });
}
try {
const now = new Date().toISOString();
const row = await createCurrentAlertRepository().createAlertRule({
userId,
hostId: hostId ?? null,
name: name.trim(),
enabled: enabled !== false,
triggerType,
thresholdValue: thresholdValue ?? null,
thresholdDurationSeconds: thresholdDurationSeconds ?? null,
cooldownMinutes: cooldownMinutes ?? 15,
channels,
now,
});
res.status(201).json({ ...row, channels });
} catch (err) {
databaseLogger.error("Failed to create alert rule", {
operation: "create_alert_rule",
error: err,
});
res.status(500).json({ error: "Failed to create alert rule" });
}
});
/**
* @openapi
* /alert-rules/{id}:
* put:
* summary: Update an alert rule
* tags:
* - Alerts
*/
router.put("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const repository = createCurrentAlertRepository();
const existing = await repository.findAlertRuleForUser(ruleId, userId);
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
const {
name,
hostId,
enabled,
triggerType,
thresholdValue,
thresholdDurationSeconds,
cooldownMinutes,
channels,
} = req.body as {
name?: string;
hostId?: number | null;
enabled?: boolean;
triggerType?: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
};
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
return res.status(400).json({ error: "Invalid triggerType" });
}
try {
const row = await repository.updateAlertRule(ruleId, userId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(hostId !== undefined ? { hostId: hostId ?? null } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(triggerType !== undefined ? { triggerType } : {}),
...(thresholdValue !== undefined
? { thresholdValue: thresholdValue ?? null }
: {}),
...(thresholdDurationSeconds !== undefined
? { thresholdDurationSeconds: thresholdDurationSeconds ?? null }
: {}),
...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
...(channels !== undefined ? { channels } : {}),
now: new Date().toISOString(),
});
if (!row) return res.status(404).json({ error: "Alert rule not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update alert rule", {
operation: "update_alert_rule",
error: err,
});
res.status(500).json({ error: "Failed to update alert rule" });
}
});
/**
* @openapi
* /alert-rules/{id}:
* delete:
* summary: Delete an alert rule
* tags:
* - Alerts
*/
router.delete("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const deleted = await createCurrentAlertRepository().deleteAlertRule(
ruleId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Alert rule not found" });
res.json({ success: true });
});
// ---- Alert Firings ----
/**
* @openapi
* /alert-firings:
* get:
* summary: List alert firings for the current user
* tags:
* - Alerts
*/
router.get("/alert-firings", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = Number(req.query.offset) || 0;
const acknowledgedParam = req.query.acknowledged;
try {
const acknowledged =
acknowledgedParam === "true"
? true
: acknowledgedParam === "false"
? false
: undefined;
res.json(
await createCurrentAlertRepository().listAlertFirings({
userId,
acknowledged,
limit,
offset,
}),
);
} catch (err) {
databaseLogger.error("Failed to list alert firings", {
operation: "list_alert_firings",
error: err,
});
res.status(500).json({ error: "Failed to list alert firings" });
}
});
/**
* @openapi
* /alert-firings/{id}/acknowledge:
* post:
* summary: Acknowledge an alert firing
* tags:
* - Alerts
*/
router.post(
"/alert-firings/:id/acknowledge",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const firingId = Number(req.params.id);
await createCurrentAlertRepository().acknowledgeFiring(firingId, userId);
res.json({ success: true });
},
);
/**
* @openapi
* /alert-firings/acknowledge-all:
* post:
* summary: Acknowledge all alert firings for the current user
* tags:
* - Alerts
*/
router.post("/alert-firings/acknowledge-all", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentAlertRepository().acknowledgeAllFirings(userId);
res.json({ success: true });
});
export default router;
-282
View File
@@ -1,282 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CacheEntry,
TermixAlert,
} from "../../../types/index.js";
import express from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { createCurrentDismissedAlertRepository } from "../repositories/factory.js";
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Docs";
const ALERTS_FILE = "main/termix-alerts.json";
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = "termix_alerts";
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
authLogger.warn("GitHub API returned error status", {
operation: "alerts_fetch",
status: response.status,
statusText: response.statusText,
});
throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
}
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
authLogger.error("Failed to fetch alerts from GitHub", {
operation: "alerts_fetch",
error: getErrorMessage(error),
});
return [];
}
}
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /alerts:
* get:
* summary: Get active alerts
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of active alerts.
* 500:
* description: Failed to fetch alerts.
*/
router.get("/", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertIds = new Set(
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
userId,
),
);
const activeAlertsForUser = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: activeAlertsForUser,
cached: alertCache.get("termix_alerts") !== null,
total_count: activeAlertsForUser.length,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* post:
* summary: Dismiss an alert
* description: Marks an alert as dismissed for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert dismissed successfully.
* 400:
* description: Alert ID is required.
* 409:
* description: Alert already dismissed.
* 500:
* description: Failed to dismiss alert.
*/
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
authLogger.warn("Missing alertId in dismiss request", { userId });
return res.status(400).json({ error: "Alert ID is required" });
}
const existingDismissal =
await createCurrentDismissedAlertRepository().findForUser(
userId,
alertId,
);
if (existingDismissal) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
await createCurrentDismissedAlertRepository().create(userId, alertId);
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
authLogger.error("Failed to dismiss alert", error);
res.status(500).json({ error: "Failed to dismiss alert" });
}
});
/**
* @openapi
* /alerts/dismissed:
* get:
* summary: Get dismissed alerts
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of dismissed alerts.
* 500:
* description: Failed to fetch dismissed alerts.
*/
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const dismissedAlertRecords =
await createCurrentDismissedAlertRepository().listByUserId(userId);
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length,
});
} catch (error) {
authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* delete:
* summary: Undismiss an alert
* description: Removes an alert from the dismissed list for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert undismissed successfully.
* 400:
* description: Alert ID is required.
* 404:
* description: Dismissed alert not found.
* 500:
* description: Failed to undismiss alert.
*/
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
return res.status(400).json({ error: "Alert ID is required" });
}
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
userId,
alertId,
);
if (!deleted) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
} catch (error) {
authLogger.error("Failed to undismiss alert", error);
res.status(500).json({ error: "Failed to undismiss alert" });
}
});
export default router;
@@ -0,0 +1,107 @@
/**
* The 2.8 login URLs clients built against 2.8 (Termix-Mobile) still call.
* Each forwards to the login pipeline for the method it names, so none of
* them can sign anyone in on its own. Old identity provider callbacks are
* redirected by the owning plugin's manifest (contributes.http.legacyRedirects).
*/
import type { Router } from "express";
import { authLogger } from "../../utils/logger.js";
import {
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} from "./auth-routes.js";
export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/{method}/authorize:
* get:
* summary: Start a redirect login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile, which call /users/oidc/authorize. Same as /users/auth/{method}/start, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* - in: query
* name: providerId
* schema:
* type: string
* - in: query
* name: rememberMe
* schema:
* type: boolean
* responses:
* 200:
* description: The authorization URL in auth_url.
* 404:
* description: That login method is not available.
*/
router.get("/:method/authorize", (req, res) =>
startRedirectLogin(
req,
res,
String(req.params.method),
typeof req.query.providerId === "string" && req.query.providerId
? req.query.providerId
: null,
),
);
/**
* @openapi
* /users/sso-providers:
* get:
* summary: List sign-in providers (2.8 route)
* description: Kept for 2.8 clients. Every enabled instance of every external login method, in the 2.8 shape. /users/auth/methods is the current route.
* tags:
* - Auth
* responses:
* 200:
* description: Providers.
*/
router.get("/sso-providers", async (_req, res) => {
try {
res.json(await listLegacySsoProviders());
} catch (error) {
authLogger.error("Failed to list SSO providers", error);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/{method}/login:
* post:
* summary: Form login (2.8 route)
* description: Kept for 2.8 clients, which call /users/ldap/login. Same as /users/auth/{method}/verify, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Invalid credentials.
* 404:
* description: That login method is not available.
*/
router.post("/:method/login", (req, res) =>
verifyFormLogin(
req,
res,
String(req.params.method),
req.body?.providerId != null ? String(req.body.providerId) : null,
),
);
}
+480
View File
@@ -0,0 +1,480 @@
/**
* The generic login surface: every registered login method and second factor
* is reachable here, core's and plugins' alike. The older per-method routes
* (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers
* so existing clients keep working.
*/
import type { Request, Response, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
import {
readPendingLogin,
redirectWithLoginError,
respondWithLogin,
respondWithRedirectLogin,
sendLoginError,
verifySecondFactorAndRespond,
evaluateSecondFactors,
} from "../../auth/login-pipeline.js";
import {
getLoginMethod,
listLoginMethods,
type LoginMethod,
} from "../../auth/registry.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
export interface PublicLoginMethod {
id: string;
pluginId: string;
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
}
async function enabledInstances(method: LoginMethod) {
return (await method.describe!()).filter((instance) => instance.enabled);
}
/** External methods with at least one enabled instance. */
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
ensureCoreLoginProviders();
const found: LoginMethod[] = [];
for (const method of listLoginMethods()) {
if (!method.external) continue;
if (!method.describe) {
found.push(method);
continue;
}
try {
if ((await enabledInstances(method)).length > 0) found.push(method);
} catch {
// A method that cannot describe itself signs nobody in.
}
}
return found;
}
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
export async function listLegacySsoProviders(): Promise<
Array<{
id: number | string;
name: string;
type: string;
displayOrder: number;
}>
> {
const providers: Array<{
id: number | string;
name: string;
type: string;
displayOrder: number;
}> = [];
for (const method of await listExternalLoginMethods()) {
if (!method.describe) continue;
for (const instance of await enabledInstances(method)) {
const numeric = Number(instance.id);
providers.push({
id: Number.isInteger(numeric) ? numeric : instance.id,
name: instance.label,
type: instance.type ?? method.id,
displayOrder: providers.length,
});
}
}
return providers;
}
/** What the login screen may know: ids, labels and enabled instances. */
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
ensureCoreLoginProviders();
const methods: PublicLoginMethod[] = [];
for (const method of listLoginMethods()) {
let instances: PublicLoginMethod["instances"] = [];
if (method.describe) {
try {
instances = (await method.describe())
.filter((instance) => instance.enabled)
.map((instance) => ({
id: instance.id,
label: instance.label,
...(instance.autoStart && method.kind === "redirect"
? { autoStart: true }
: {}),
}));
} catch (error) {
authLogger.warn("Login method could not describe itself", {
operation: "login_method_describe",
methodId: method.id,
error,
});
continue;
}
if (instances.length === 0) continue;
}
methods.push({
id: method.id,
pluginId: method.pluginId,
kind: method.kind,
labelKey: method.labelKey,
icon: method.icon,
instances,
});
}
return methods;
}
function instanceParam(req: Request): string | null {
const value = req.query.instance ?? req.body?.instanceId;
return typeof value === "string" && value ? value : null;
}
/**
* Trusted proxy login and external methods don't mix: the proxy already
* decided who the user is.
*/
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
res.status(409).json({
error:
"External login is disabled while trusted proxy authentication is enabled",
});
return true;
}
function methodOr404(req: Request, res: Response, id?: string) {
ensureCoreLoginProviders();
const method = getLoginMethod(id ?? String(req.params.methodId));
if (!method) {
res.status(404).json({ error: "Unknown login method" });
return null;
}
if (refusedByTrustedProxy(method, res)) return null;
return method;
}
/**
* A redirect method's callback, for core's route and for a plugin's own
* public route through ctx.auth.completeRedirectLogin.
*/
export async function handleRedirectCallback(
method: LoginMethod,
req: Request,
res: Response,
): Promise<void> {
if (refusedByTrustedProxy(method, res)) return;
if (!method.callback) {
res.status(400).json({ error: "Not a redirect login method" });
return;
}
let identity: VerifiedIdentity;
try {
identity = await method.callback(req as never);
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
if (returnTo) return redirectWithLoginError(res, returnTo, error);
sendLoginError(res, error);
return;
}
await respondWithRedirectLogin(
req,
res,
identity,
{ methodId: method.id, rememberMe: !!identity.rememberMe },
isExternalTokenCallback,
);
}
/** Starts a redirect method, answering with its URL. */
export async function startRedirectLogin(
req: Request,
res: Response,
methodId: string,
instanceId: string | null,
): Promise<void> {
const method = methodOr404(req, res, methodId);
if (!method) return;
if (method.kind !== "redirect" || !method.start) {
res.status(400).json({ error: "Not a redirect login method" });
return;
}
try {
const result = await method.start(req as never, instanceId);
// auth_url is the name 2.8 clients read.
res.json({ ...result, auth_url: result.redirectUrl });
} catch (error) {
sendLoginError(res, error);
}
}
/** Runs a form method and answers with a session or a second-factor step. */
export async function verifyFormLogin(
req: Request,
res: Response,
methodId: string,
instanceId: string | null,
): Promise<void> {
const method = methodOr404(req, res, methodId);
if (!method) return;
if (method.kind !== "form" || !method.verify) {
res.status(400).json({ error: "Not a form login method" });
return;
}
try {
const identity = await method.verify(req as never, instanceId);
await respondWithLogin(req, res, identity, {
methodId: method.id,
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
});
} catch (error) {
if (!(error instanceof LoginMethodError)) {
authLogger.error("Form login failed", error, { methodId: method.id });
}
sendLoginError(res, error);
}
}
export function registerAuthRoutes(router: Router): void {
/**
* @openapi
* /users/auth/methods:
* get:
* summary: List login methods
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
* tags:
* - Auth
* responses:
* 200:
* description: Login methods.
*/
router.get("/auth/methods", async (_req, res) => {
try {
res.json({ methods: await listPublicLoginMethods() });
} catch (error) {
authLogger.error("Failed to list login methods", error);
res.status(500).json({ error: "Failed to list login methods" });
}
});
/**
* @openapi
* /users/auth/second-factor/{factorId}/challenge:
* post:
* summary: Start a second factor
* description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor.
* tags:
* - Auth
* parameters:
* - in: path
* name: factorId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Challenge data.
* 401:
* description: No pending login.
* 404:
* description: Factor not enabled for this user.
*/
router.post("/auth/second-factor/:factorId/challenge", async (req, res) => {
try {
const lookup = await readPendingLogin(req);
if (!lookup) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const { required } = await evaluateSecondFactors(lookup.userId);
const factor = required.find(
(candidate) => candidate.id === req.params.factorId,
);
if (!factor) {
return res.status(404).json({ error: "Second factor not enabled" });
}
res.json({
challenge: (await factor.challenge?.(lookup.userId)) ?? null,
});
} catch (error) {
sendLoginError(res, error);
}
});
/**
* @openapi
* /users/auth/second-factor/{factorId}/verify:
* post:
* summary: Finish a second factor
* description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie.
* tags:
* - Auth
* parameters:
* - in: path
* name: factorId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful.
* 401:
* description: Wrong answer or no pending login.
* 403:
* description: An enrolled factor is unavailable.
* 429:
* description: Too many attempts.
*/
router.post("/auth/second-factor/:factorId/verify", async (req, res) => {
try {
await verifySecondFactorAndRespond(req, res, String(req.params.factorId));
} catch (error) {
sendLoginError(res, error);
}
});
/**
* @openapi
* /users/totp/verify-login:
* post:
* summary: Finish a login with a second factor (2.8 route)
* description: Kept for clients built against 2.8, such as Termix-Mobile. Verifies the pending login against the factor named in `factor`, or the user's first enrolled factor, the same way as /users/auth/second-factor/{factorId}/verify.
* tags:
* - Auth
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* temp_token:
* type: string
* totp_code:
* type: string
* factor:
* type: string
* responses:
* 200:
* description: Login finished.
* 400:
* description: Token and code are required.
* 401:
* description: Invalid temporary token or code.
*/
router.post("/totp/verify-login", async (req, res) => {
if (!req.body?.temp_token || !req.body?.totp_code) {
return res.status(400).json({ error: "Token and code are required" });
}
try {
await verifySecondFactorAndRespond(
req,
res,
typeof req.body.factor === "string" ? req.body.factor : undefined,
);
} catch (error) {
sendLoginError(res, error);
}
});
/**
* @openapi
* /users/auth/{methodId}/start:
* get:
* summary: Start a redirect login
* description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* - in: query
* name: instance
* schema:
* type: string
* responses:
* 200:
* description: Redirect URL.
* 404:
* description: Unknown login method.
*/
router.get("/auth/:methodId/start", (req, res) =>
startRedirectLogin(
req,
res,
String(req.params.methodId),
instanceParam(req),
),
);
const callback = async (req: Request, res: Response) => {
const method = methodOr404(req, res);
if (!method) return;
await handleRedirectCallback(method, req, res);
};
/**
* @openapi
* /users/auth/{methodId}/callback:
* get:
* summary: Redirect login callback
* description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 302:
* description: Back to the app.
* post:
* summary: Redirect login callback (form post)
* description: Same as the GET form, for providers that post their answer.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 302:
* description: Back to the app.
*/
router.get("/auth/:methodId/callback", callback);
router.post("/auth/:methodId/callback", callback);
/**
* @openapi
* /users/auth/{methodId}/verify:
* post:
* summary: Form login
* description: Checks what the user typed for a form login method, then issues a session or asks for a second factor.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Wrong credentials.
* 404:
* description: Unknown login method.
*/
router.post("/auth/:methodId/verify", (req, res) =>
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
);
}
-822
View File
@@ -1,822 +0,0 @@
import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type {
AutomationDefinition,
Step,
Trigger,
} from "../../../types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import {
getAuditUsername,
getRequestMeta,
logAudit,
} from "../../utils/audit-logger.js";
import { createCurrentAutomationRepository } from "../repositories/factory.js";
import type { AutomationRow } from "../repositories/automation-repository.js";
import { AutomationEngine } from "../../automations/engine.js";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
} from "../../automations/cron.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const TRIGGER_KINDS = new Set([
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"internal_event",
"webhook",
]);
const STEP_TYPES = new Set([
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"run_automation",
"stop",
]);
const OPERATORS = new Set([
">",
"<",
">=",
"<=",
"==",
"!=",
"contains",
"not_contains",
"changed",
]);
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) && id > 0 ? id : null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Validates a definition before it is stored. The engine treats the stored
* blob as trusted, so everything it relies on is checked once here.
*/
export function validateDefinition(value: unknown): {
ok: boolean;
error?: string;
definition?: AutomationDefinition;
} {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Definition must be an object" };
}
const candidate = value as Partial<AutomationDefinition>;
const trigger = candidate.trigger as Trigger | undefined;
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
return { ok: false, error: "Unknown or missing trigger kind" };
}
if (trigger.kind === "metric_threshold") {
if (!OPERATORS.has(trigger.operator)) {
return { ok: false, error: "Unknown comparison operator" };
}
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
return { ok: false, error: "Threshold value must be a number" };
}
if (!trigger.metric?.path) {
return { ok: false, error: "Trigger is missing a metric" };
}
}
if (trigger.kind === "schedule") {
const hasInterval =
typeof trigger.intervalSeconds === "number" &&
trigger.intervalSeconds > 0;
const hasCron = isNonEmptyString(trigger.cron);
if (!hasInterval && !hasCron) {
return { ok: false, error: "Schedule needs an interval or a cron" };
}
if (hasCron && !isValidCron(trigger.cron as string)) {
return { ok: false, error: "Cron expression is not valid" };
}
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
return { ok: false, error: "Interval must be at least 60 seconds" };
}
if (
isNonEmptyString(trigger.timezone) &&
!isValidTimezone(trigger.timezone)
) {
return { ok: false, error: "Time zone is not valid" };
}
}
const steps = candidate.steps;
if (!Array.isArray(steps)) {
return { ok: false, error: "Definition must include a steps array" };
}
const seen = new Set<string>();
const stepError = validateSteps(steps as Step[], seen);
if (stepError) return { ok: false, error: stepError };
return {
ok: true,
definition: {
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
trigger,
steps: steps as Step[],
},
};
}
function validateSteps(steps: Step[], seen: Set<string>): string | null {
for (const step of steps) {
if (!step || typeof step !== "object") return "Step must be an object";
if (!isNonEmptyString(step.id)) return "Every step needs an id";
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
seen.add(step.id);
if (!STEP_TYPES.has(step.type)) {
return `Unknown step type: ${step.type}`;
}
if (step.type === "if") {
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
return "Condition needs a valid operator";
}
const thenError = validateSteps(step.then ?? [], seen);
if (thenError) return thenError;
const elseError = validateSteps(step.else ?? [], seen);
if (elseError) return elseError;
}
if (step.type === "http" && !isNonEmptyString(step.url)) {
return "HTTP steps need a URL";
}
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
return "Command steps need a command";
}
if (step.type === "wait" && typeof step.seconds !== "number") {
return "Wait steps need a number of seconds";
}
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
return "Variable steps need a name";
}
}
return null;
}
/** Never leak a webhook token hash to the client. */
function serialize(row: AutomationRow) {
let definition: AutomationDefinition | null = null;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
definition = null;
}
if (definition?.trigger?.kind === "webhook") {
definition = {
...definition,
trigger: { ...definition.trigger, tokenHash: "" },
};
}
return { ...row, definition };
}
async function syncSchedule(
automationId: number,
definition: AutomationDefinition,
): Promise<void> {
const repository = createCurrentAutomationRepository();
if (definition.trigger?.kind !== "schedule") {
await repository.deleteSchedule(automationId);
return;
}
const trigger = definition.trigger;
await repository.upsertSchedule({
automationId,
cron: trigger.cron ?? null,
intervalSeconds: trigger.intervalSeconds ?? null,
timezone: trigger.timezone ?? null,
nextDueAt: computeNextDueAt({
cron: trigger.cron,
intervalSeconds: trigger.intervalSeconds,
timezone: trigger.timezone,
}),
});
}
/**
* @openapi
* /automations:
* get:
* summary: List the current user's automations
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
* tags:
* - Automations
* responses:
* 200:
* description: List of automations.
* 403:
* description: Missing the automations.view permission.
*/
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await createCurrentAutomationRepository().list(userId);
res.json(rows.map(serialize));
} catch (error) {
databaseLogger.error("Failed to list automations", error, {
operation: "automation_list_error",
userId,
});
res.status(500).json({ error: "Failed to list automations" });
}
},
);
/**
* @openapi
* /automations/{id}:
* get:
* summary: Fetch a single automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The automation.
* 404:
* description: Automation not found.
*/
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const row = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!row) return res.status(404).json({ error: "Automation not found" });
res.json(serialize(row));
} catch (error) {
databaseLogger.error("Failed to fetch automation", error, {
operation: "automation_get_error",
userId,
});
res.status(500).json({ error: "Failed to fetch automation" });
}
},
);
/**
* @openapi
* /automations:
* post:
* summary: Create an automation
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
* tags:
* - Automations
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* definition:
* type: object
* responses:
* 201:
* description: The created automation.
* 400:
* description: Validation failed.
*/
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, description, enabled, definition, concurrencyPolicy } =
req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Name is required" });
}
const validated = validateDefinition(definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// A webhook trigger's token is shown once here and only stored hashed.
let webhookToken: string | undefined;
if (validated.definition.trigger.kind === "webhook") {
webhookToken = crypto.randomBytes(32).toString("hex");
validated.definition = {
...validated.definition,
trigger: {
kind: "webhook",
tokenHash: hashToken(webhookToken),
},
};
}
try {
const repository = createCurrentAutomationRepository();
const created = await repository.create({
userId,
name: name.trim(),
description: description ?? null,
enabled: enabled !== false,
definition: JSON.stringify(validated.definition),
concurrencyPolicy,
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
});
await syncSchedule(created.id, validated.definition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "create_automation",
resourceType: "automation",
resourceId: String(created.id),
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({ ...serialize(created), webhookToken });
} catch (error) {
databaseLogger.error("Failed to create automation", error, {
operation: "automation_create_error",
userId,
});
res.status(500).json({ error: "Failed to create automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* put:
* summary: Update an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The updated automation.
* 400:
* description: Validation failed.
* 404:
* description: Automation not found.
*/
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const repository = createCurrentAutomationRepository();
const existing = await repository.findForUser(id, userId);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
const update: Record<string, unknown> = {};
if (req.body?.name !== undefined) {
if (!isNonEmptyString(req.body.name)) {
return res.status(400).json({ error: "Name cannot be empty" });
}
update.name = req.body.name.trim();
}
if (req.body?.description !== undefined) {
update.description = req.body.description;
}
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
if (req.body?.concurrencyPolicy !== undefined) {
update.concurrencyPolicy = req.body.concurrencyPolicy;
}
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
let parsedDefinition: AutomationDefinition | null = null;
if (req.body?.definition !== undefined) {
const validated = validateDefinition(req.body.definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// Keep the stored token hash: the raw token is only ever shown once.
if (validated.definition.trigger.kind === "webhook") {
const previous = JSON.parse(
existing.definition,
) as AutomationDefinition;
const previousHash =
previous.trigger?.kind === "webhook"
? previous.trigger.tokenHash
: "";
validated.definition = {
...validated.definition,
trigger: { kind: "webhook", tokenHash: previousHash },
};
}
parsedDefinition = validated.definition;
update.definition = JSON.stringify(validated.definition);
}
try {
const updated = await repository.update(id, userId, update);
if (!updated) {
return res.status(404).json({ error: "Automation not found" });
}
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "update_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: updated.name,
ipAddress,
userAgent,
success: true,
});
res.json(serialize(updated));
} catch (error) {
databaseLogger.error("Failed to update automation", error, {
operation: "automation_update_error",
userId,
});
res.status(500).json({ error: "Failed to update automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* delete:
* summary: Delete an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: Automation not found.
*/
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await createCurrentAutomationRepository().delete(
id,
userId,
);
if (!deleted) {
return res.status(404).json({ error: "Automation not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "delete_automation",
resourceType: "automation",
resourceId: String(id),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to delete automation", error, {
operation: "automation_delete_error",
userId,
});
res.status(500).json({ error: "Failed to delete automation" });
}
},
);
/**
* @openapi
* /automations/{id}/run:
* post:
* summary: Run an automation now
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* dryRun:
* type: boolean
* responses:
* 200:
* description: The run outcome.
* 404:
* description: Automation not found.
*/
router.post(
"/:id/run",
authenticateJWT,
permissionManager.requirePermission("automations.run"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const existing = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
try {
const outcome = await AutomationEngine.getInstance().run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun: req.body?.dryRun === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "run_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: existing.name,
details: JSON.stringify({
status: outcome.status,
dryRun: req.body?.dryRun === true,
}),
ipAddress,
userAgent,
success: outcome.status === "success",
});
res.json(outcome);
} catch (error) {
databaseLogger.error("Failed to run automation", error, {
operation: "automation_run_error",
userId,
});
res.status(500).json({ error: "Failed to run automation" });
}
},
);
/**
* @openapi
* /automations/runs:
* get:
* summary: List automation runs
* tags:
* - Automations
* parameters:
* - in: query
* name: automationId
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Recent runs, newest first.
*/
router.get(
"/runs/history",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const runs = await createCurrentAutomationRepository().listRuns(userId, {
automationId: parseId(req.query.automationId) ?? undefined,
limit: Number(req.query.limit) || 50,
offset: Number(req.query.offset) || 0,
});
res.json(runs);
} catch (error) {
databaseLogger.error("Failed to list automation runs", error, {
operation: "automation_runs_error",
userId,
});
res.status(500).json({ error: "Failed to list runs" });
}
},
);
/**
* @openapi
* /automations/runs/{runId}/steps:
* get:
* summary: Step-by-step results for a run
* tags:
* - Automations
* parameters:
* - in: path
* name: runId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The run's steps in order.
* 404:
* description: Run not found.
*/
router.get(
"/runs/:runId/steps",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const runId = parseId(req.params.runId);
if (runId === null) return res.status(400).json({ error: "Invalid id" });
try {
const repository = createCurrentAutomationRepository();
const run = await repository.findRunForUser(runId, userId);
if (!run) return res.status(404).json({ error: "Run not found" });
res.json(await repository.listRunSteps(runId));
} catch (error) {
databaseLogger.error("Failed to list run steps", error, {
operation: "automation_run_steps_error",
userId,
});
res.status(500).json({ error: "Failed to list run steps" });
}
},
);
/**
* @openapi
* /automations/webhook/{token}:
* post:
* summary: Trigger an automation from an external system
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
* tags:
* - Automations
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 202:
* description: The run was accepted.
* 404:
* description: No automation matches that token.
*/
router.post("/webhook/:token", async (req: Request, res: Response) => {
const token = req.params.token;
if (!isNonEmptyString(token) || token.length < 32) {
return res.status(404).json({ error: "Not found" });
}
try {
const repository = createCurrentAutomationRepository();
const candidates = await repository.listAllEnabled();
const wanted = hashToken(token);
const match = candidates.find((row) => {
try {
const definition = JSON.parse(row.definition) as AutomationDefinition;
if (definition.trigger?.kind !== "webhook") return false;
return timingSafeEqual(definition.trigger.tokenHash, wanted);
} catch {
return false;
}
});
if (!match) return res.status(404).json({ error: "Not found" });
const outcome = await AutomationEngine.getInstance().run({
automationId: match.id,
triggerType: "webhook",
triggerContext: {
body: req.body ?? {},
receivedAt: new Date().toISOString(),
},
});
res.status(202).json({ runId: outcome.runId, status: outcome.status });
} catch (error) {
databaseLogger.error("Webhook automation failed", error, {
operation: "automation_webhook_error",
});
res.status(500).json({ error: "Failed to run automation" });
}
});
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
function timingSafeEqual(a: string, b: string): boolean {
const left = Buffer.from(a || "", "utf8");
const right = Buffer.from(b || "", "utf8");
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
export default router;
@@ -1,293 +0,0 @@
import type {
AuthenticatedRequest,
TunnelConnection,
} from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js";
import { createCurrentC2sTunnelPresetRepository } from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function parsePreset(row: C2sTunnelPresetRecord) {
return {
...row,
config: JSON.parse(row.config) as TunnelConnection[],
};
}
function validateConfig(config: unknown): config is TunnelConnection[] {
if (!Array.isArray(config)) return false;
return config.every((item) => {
if (!item || typeof item !== "object") return false;
const tunnel = item as Partial<TunnelConnection>;
const mode = tunnel.mode || tunnel.tunnelType;
return (
tunnel.scope === "c2s" &&
(mode === "local" || mode === "remote" || mode === "dynamic") &&
typeof tunnel.sourcePort === "number" &&
tunnel.sourcePort >= 1 &&
tunnel.sourcePort <= 65535 &&
(mode === "dynamic" ||
(typeof tunnel.endpointPort === "number" &&
tunnel.endpointPort >= 1 &&
tunnel.endpointPort <= 65535))
);
});
}
/**
* @openapi
* /c2s-tunnel-presets:
* get:
* summary: List client tunnel presets
* description: Returns the authenticated user's saved client-to-server tunnel presets.
* tags:
* - Tunnel Presets
* responses:
* 200:
* description: List of tunnel presets.
* 401:
* description: Authentication required.
* 500:
* description: Failed to list presets.
*/
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result =
await createCurrentC2sTunnelPresetRepository().listByUserId(userId);
res.json(result.map(parsePreset));
} catch (error) {
authLogger.error("Failed to fetch C2S tunnel presets", error);
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
}
},
);
/**
* @openapi
* /c2s-tunnel-presets:
* post:
* summary: Create a client tunnel preset
* description: Saves a named client-to-server tunnel configuration for the authenticated user.
* tags:
* - Tunnel Presets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* config:
* type: array
* items:
* type: object
* responses:
* 200:
* description: Preset created.
* 400:
* description: Invalid name or config.
* 401:
* description: Authentication required.
* 500:
* description: Failed to create preset.
*/
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
const trimmedName = name.trim();
try {
const presetRepository = createCurrentC2sTunnelPresetRepository();
if (await presetRepository.hasNameForUser(userId, trimmedName)) {
return res.status(409).json({ error: "Preset name already exists" });
}
const created = await presetRepository.createForUser(userId, {
name: trimmedName,
config: JSON.stringify(config),
platform: platform?.trim() || null,
computerName: computerName?.trim() || null,
});
databaseLogger.info("C2S tunnel preset created", {
operation: "c2s_tunnel_preset_create",
userId,
presetId: created.id,
});
res.status(201).json(parsePreset(created));
} catch (error) {
authLogger.error("Failed to create C2S tunnel preset", error);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
}
},
);
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* put:
* summary: Update a client tunnel preset
* description: Updates the name or config of one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset updated.
* 400:
* description: Invalid name or config.
* 404:
* description: Preset not found.
* 500:
* description: Failed to update preset.
*/
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const presetRepository = createCurrentC2sTunnelPresetRepository();
const existing = await presetRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Preset not found" });
}
const updateFields: Parameters<typeof presetRepository.updateForUser>[2] =
{};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
if (await presetRepository.hasNameForUser(userId, trimmedName, id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updateFields.name = trimmedName;
}
if (config !== undefined) {
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
updateFields.config = JSON.stringify(config);
}
if (platform !== undefined)
updateFields.platform = platform?.trim() || null;
if (computerName !== undefined)
updateFields.computerName = computerName?.trim() || null;
const updated = await presetRepository.updateForUser(
userId,
id,
updateFields,
);
res.json(parsePreset(updated ?? existing));
} catch (error) {
authLogger.error("Failed to update C2S tunnel preset", error);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
}
},
);
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* delete:
* summary: Delete a client tunnel preset
* description: Deletes one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset deleted.
* 404:
* description: Preset not found.
* 500:
* description: Failed to delete preset.
*/
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const presetRepository = createCurrentC2sTunnelPresetRepository();
const existing = await presetRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Preset not found" });
}
await presetRepository.deleteForUser(userId, id);
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete C2S tunnel preset", error);
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
}
},
);
export default router;
@@ -4,28 +4,56 @@ import type {
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import ssh2Pkg from "ssh2";
import type { Client as SshClient } from "ssh2";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
const { Client } = ssh2Pkg;
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
if (message.includes("All configured authentication methods failed")) {
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
}
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
return "Could not resolve hostname or connect to server.";
}
if (message.includes("ECONNREFUSED")) {
return "Connection refused. The server may not be running or the port may be incorrect.";
}
if (message.includes("ETIMEDOUT")) {
return "Connection timed out. Check your network connection and server availability.";
}
if (
message.includes("authentication failed") ||
message.includes("Permission denied")
) {
return "Authentication failed. Please check your username and password/key.";
}
if (message === "SSH connection timeout") return "Connection timeout";
return message;
}
async function deploySSHKeyToHost(
hostConfig: Record<string, unknown>,
host: SshConnectHost,
userId: string,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.publicKey as string;
let conn: SshClient;
try {
({ client: conn } = await connectHost(host, {
userId,
purpose: "credential-deploy",
timeoutMs: 120000,
}));
} catch (error) {
return { success: false, error: describeDeployError(error) };
}
return new Promise((resolve) => {
const conn = new Client();
const connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 120000);
conn.on("ready", async () => {
clearTimeout(connectionTimeout);
void (async () => {
try {
await new Promise<void>((resolveCmd, rejectCmd) => {
const cmdTimeout = setTimeout(() => {
@@ -238,121 +266,7 @@ async function deploySSHKeyToHost(
error: getErrorMessage(error, "Deployment failed"),
});
}
});
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
let errorMessage = err.message;
if (
err.message.includes("All configured authentication methods failed")
) {
errorMessage =
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage = "Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"Authentication failed. Please check your username and password/key.";
}
resolve({ success: false, error: errorMessage });
});
try {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
connectionConfig.privateKey = preparePrivateKeyForSSH2(
privateKey,
hostConfig.keyPassword as string | undefined,
);
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} catch (keyError) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid SSH key format: ${getErrorMessage(keyError)}`,
});
return;
}
} else {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
});
return;
}
conn.connect(connectionConfig);
} catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: getErrorMessage(error, "Connection failed"),
});
}
})();
});
}
@@ -456,6 +370,8 @@ export function registerCredentialDeployRoutes(
error: "Public key is required for deployment",
});
}
// Writing authorized_keys is an owner action, so ownership is checked
// before the shared resolver fills in the credentials.
const hostData = await repository.findHostByIdForUser(
targetHostId,
userId,
@@ -468,57 +384,19 @@ export function registerCredentialDeployRoutes(
});
}
const hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
};
if (hostData.authType === "credential" && hostData.credentialId) {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(400).json({
success: false,
error: "Authentication required for credential resolution",
});
}
try {
const hostCredential = await repository.findCredentialByIdForUser(
hostData.credentialId as number,
userId,
);
if (hostCredential) {
hostConfig.authType = hostCredential.authType;
hostConfig.username = hostCredential.username;
if (hostCredential.authType === "password") {
hostConfig.password = hostCredential.password;
} else if (hostCredential.authType === "key") {
hostConfig.privateKey =
hostCredential.privateKey || hostCredential.key;
hostConfig.keyPassword = hostCredential.keyPassword;
}
} else {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
} catch {
return res.status(500).json({
success: false,
error: "Failed to resolve host credentials",
});
}
const resolvedHost = await resolveHostById(targetHostId, userId);
if (!resolvedHost) {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
const deployResult = await deploySSHKeyToHost(
resolvedHost as unknown as SshConnectHost,
userId,
credData,
);
if (deployResult.success) {
res.json({
+40 -64
View File
@@ -1,5 +1,8 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -20,10 +23,18 @@ import {
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { parseSharedSource } from "./host-normalizers.js";
/** Built-in password and key, plus any type a plugin offers for credentials. */
function getCredentialTypes(): string[] {
ensureCoreSshAuthProviders();
return listCredentialTypes();
}
const router = express.Router();
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -110,16 +121,17 @@ router.post(
return res.status(400).json({ error: "Name is required" });
}
if (!["password", "key"].includes(authType)) {
const credentialTypes = getCredentialTypes();
if (!credentialTypes.includes(authType)) {
authLogger.warn("Invalid auth type provided", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: 'Auth type must be "password" or "key"' });
return res.status(400).json({
error: `Auth type must be one of: ${credentialTypes.join(", ")}`,
});
}
try {
@@ -659,8 +671,14 @@ router.put(
}
if (updateData.username !== undefined)
updateFields.username = updateData.username?.trim() || null;
if (updateData.authType !== undefined)
if (updateData.authType !== undefined) {
if (!getCredentialTypes().includes(updateData.authType)) {
return res.status(400).json({
error: `Auth type must be one of: ${getCredentialTypes().join(", ")}`,
});
}
updateFields.authType = updateData.authType;
}
if (updateData.keyType !== undefined)
updateFields.keyType = updateData.keyType;
@@ -801,59 +819,11 @@ router.delete(
try {
const credentialId = parseInt(id);
const credentialToDelete =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!credentialToDelete) {
const deleted = await deleteOwnedCredential(userId, credentialId);
if (!deleted) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
},
);
}
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -867,7 +837,7 @@ router.delete(
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: String(credentialToDelete.name ?? id),
resourceName: String(deleted.name ?? id),
ipAddress: cdIp,
userAgent: cdUa,
success: true,
@@ -1123,6 +1093,19 @@ function formatCredentialOutput(
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
...sharedCopyFields(credential.sharedSource),
};
}
/** A read-only copy of a credential shared with a linked desktop's account. */
function sharedCopyFields(value: unknown): Record<string, unknown> {
const shared = parseSharedSource(value);
if (!shared) return {};
return {
isShared: true,
sharedCopy: true,
ownerUsername: shared.owner || null,
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
};
}
@@ -1145,13 +1128,6 @@ function formatSSHHostOutput(
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
enableFileManager: host.enableFileManager !== false,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
@@ -0,0 +1,240 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
import { recordRecentActivity } from "../../services/recent-activity.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const serverStartTime = Date.now();
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
/**
* @openapi
* /dashboard/uptime:
* get:
* summary: Get server uptime
* description: Returns the uptime of the server in various formats.
* tags:
* - Dashboard
* responses:
* 200:
* description: Server uptime information.
* content:
* application/json:
* schema:
* type: object
* properties:
* uptimeMs:
* type: number
* uptimeSeconds:
* type: number
* formatted:
* type: string
* 500:
* description: Failed to get uptime.
*/
router.get("/uptime", authenticateJWT, async (req: Request, res: Response) => {
try {
const uptimeMs = Date.now() - serverStartTime;
const uptimeSeconds = Math.floor(uptimeMs / 1000);
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
res.json({
uptimeMs,
uptimeSeconds,
formatted: `${days}d ${hours}h ${minutes}m`,
});
} catch (err) {
dashboardLogger.error("Failed to get uptime", err);
res.status(500).json({ error: "Failed to get uptime" });
}
});
/**
* @openapi
* /dashboard/activity/recent:
* get:
* summary: Get recent activity
* description: Fetches the most recent activities for the authenticated user.
* tags:
* - Dashboard
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* description: The maximum number of activities to return.
* responses:
* 200:
* description: A list of recent activities.
* 401:
* description: Session expired.
* 500:
* description: Failed to get recent activity.
*/
router.get(
"/activity/recent",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const limit = Number(req.query.limit) || 20;
const activities =
await createCurrentRecentActivityRepository().listByUserId(
userId,
limit,
);
res.json(activities);
} catch (err) {
dashboardLogger.error("Failed to get recent activity", err);
res.status(500).json({ error: "Failed to get recent activity" });
}
},
);
/**
* @openapi
* /dashboard/activity/log:
* post:
* summary: Log a new activity
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* description: An activity type a plugin's tab declares, lowercase letters, digits and underscores.
* hostId:
* type: integer
* hostName:
* type: string
* responses:
* 200:
* description: Activity logged successfully or rate-limited.
* 400:
* description: Invalid request body.
* 401:
* description: Session expired.
* 404:
* description: Host not found or access denied.
* 500:
* description: Failed to log activity.
*/
router.post(
"/activity/log",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { type, hostId, hostName } = req.body;
if (!type || !hostId || !hostName) {
return res.status(400).json({
error: "Missing required fields: type, hostId, hostName",
});
}
const result = await recordRecentActivity(userId, {
type,
hostId,
hostName,
});
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Use lowercase letters, digits and underscores.",
});
}
if (result.status === "rate_limited") {
return res.json({
message: "Activity already logged recently (rate limited)",
});
}
if (result.status === "denied") {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
res.json({ message: "Activity logged", id: result.id });
} catch (err) {
dashboardLogger.error("Failed to log activity", err);
res.status(500).json({ error: "Failed to log activity" });
}
},
);
/**
* @openapi
* /dashboard/activity/reset:
* delete:
* summary: Reset recent activity
* description: Clears all recent activity for the authenticated user.
* tags:
* - Dashboard
* responses:
* 200:
* description: Recent activity cleared.
* 401:
* description: Session expired.
* 500:
* description: Failed to reset activity.
*/
router.delete(
"/activity/reset",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentRecentActivityRepository().deleteByUserId(userId);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
userId,
});
res.json({ message: "Recent activity cleared" });
} catch (err) {
dashboardLogger.error("Failed to reset activity", err);
res.status(500).json({ error: "Failed to reset activity" });
}
},
);
export default router;
@@ -1,263 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import {
createCurrentDashboardServiceLinkRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router();
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/**
* @openapi
* /service-links:
* get:
* summary: Get service links
* description: Returns all dashboard service links for the authenticated user.
* tags:
* - Dashboard
* responses:
* 200:
* description: List of service links.
* 500:
* description: Failed to fetch service links.
*/
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const links =
await createCurrentDashboardServiceLinkRepository().listByUserId(userId);
res.json(links);
} catch (err) {
dashboardLogger.error("Failed to fetch service links", err);
res.status(500).json({ error: "Failed to fetch service links" });
}
});
/**
* @openapi
* /service-links:
* post:
* summary: Create service link
* description: Creates a new dashboard service link for the authenticated user.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - label
* - url
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 201:
* description: Service link created.
* 400:
* description: Invalid data.
* 500:
* description: Failed to create service link.
*/
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { label, url } = req.body;
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
if (!isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const created =
await createCurrentDashboardServiceLinkRepository().createForUser(
userId,
{
label: label.trim(),
url: url.trim(),
},
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
res.status(201).json(created);
} catch (err) {
dashboardLogger.error("Failed to create service link", err);
res.status(500).json({ error: "Failed to create service link" });
}
});
/**
* @openapi
* /service-links/{id}:
* delete:
* summary: Delete service link
* description: Deletes a dashboard service link by ID.
* tags:
* - Dashboard
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Service link deleted.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to delete service link.
*/
dashboardServiceLinksRouter.delete(
"/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
try {
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const deleted =
await createCurrentDashboardServiceLinkRepository().deleteForUser(
userId,
id,
);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
}
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
} catch (err) {
dashboardLogger.error("Failed to delete service link", err);
res.status(500).json({ error: "Failed to delete service link" });
}
},
);
/**
* @openapi
* /service-links/{id}:
* put:
* summary: Update service link
* description: Updates label or url of a dashboard service link.
* tags:
* - Dashboard
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 200:
* description: Service link updated.
* 400:
* description: Invalid data.
* 404:
* description: Not found.
* 500:
* description: Failed to update service link.
*/
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
const { label, url } = req.body;
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
if (url !== undefined && !isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Partial<{ label: string; url: string }> = {};
if (isNonEmptyString(label)) updates.label = label.trim();
if (isNonEmptyString(url)) updates.url = url.trim();
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
}
const updated =
await createCurrentDashboardServiceLinkRepository().updateForUser(
userId,
id,
updates,
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
res.json(updated);
} catch (err) {
dashboardLogger.error("Failed to update service link", err);
res.status(500).json({ error: "Failed to update service link" });
}
});
@@ -1,48 +1,26 @@
import { authLogger } from "../../utils/logger.js";
import {
createCurrentAiRepository,
createCurrentAlertRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentC2sTunnelPresetRepository,
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
createCurrentHostFolderRepository,
createCurrentHostMetricsPreferenceRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentNetworkTopologyRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTmuxSessionTagRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository,
} from "../repositories/factory.js";
export async function deleteUserAndRelatedData(
@@ -68,9 +46,10 @@ export async function deleteUserAndRelatedData(
userId,
);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId);
// Plugins drop or anonymize their own rows on user.deleted, or rely on
// their refUser() foreign keys cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId,
@@ -81,27 +60,14 @@ export async function deleteUserAndRelatedData(
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAiRepository().deleteByUserId(userId);
await createCurrentAlertRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentTransferRecentRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
userId,
@@ -110,18 +76,8 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
await createCurrentHomepageItemRepository().deleteByUserId(userId);
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
await createCurrentC2sTunnelPresetRepository().deleteByUserId(userId);
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
// Plugin tables with a refUser() column cascade on the user row.
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
);
}
/**
* Whether this backend may hand out the desktop auto-session at all: the
* embedded desktop backend, or a development backend the desktop app points
* at. A production server never does, whoever asks.
*/
export function allowsDesktopAutoSession(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
}
export function extractBearerOrCookieToken(req: Request): string | undefined {
const cookieToken = (req as Request & { cookies?: Record<string, string> })
.cookies?.jwt;
File diff suppressed because it is too large. Load diff
@@ -1,120 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import https from "https";
import http from "http";
export const homepageFaviconRouter = express.Router();
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
const faviconCache = new Map<
string,
{ data: Buffer; contentType: string; expires: number }
>();
const CACHE_SIZE = 100;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const MAX_FAVICON_BYTES = 512 * 1024;
function evictIfNeeded() {
if (faviconCache.size >= CACHE_SIZE) {
const oldest = faviconCache.keys().next().value;
if (oldest) faviconCache.delete(oldest);
}
}
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
return new Promise((resolve, reject) => {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: 5000 }, (res) => {
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
res.destroy();
reject(error);
};
res.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_FAVICON_BYTES) {
fail(new Error("Favicon response too large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
if (settled) return;
settled = true;
resolve({
data: Buffer.concat(chunks),
contentType: res.headers["content-type"] || "image/x-icon",
});
});
res.on("error", fail);
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Favicon fetch timeout"));
});
});
}
/**
* @openapi
* /homepage/favicon:
* get:
* summary: Proxy favicon fetch
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Favicon image.
* 400:
* description: Invalid URL.
* 500:
* description: Failed to fetch favicon.
*/
homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
const rawUrl = req.query.url as string;
if (!rawUrl) return res.status(400).json({ error: "url is required" });
let domain: string;
try {
domain = new URL(rawUrl).hostname;
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = faviconCache.get(domain);
if (cached && cached.expires > Date.now()) {
res.setHeader("Content-Type", cached.contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
return res.send(cached.data);
}
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
try {
const { data, contentType } = await fetchUrl(faviconUrl);
evictIfNeeded();
faviconCache.set(domain, {
data,
contentType,
expires: Date.now() + CACHE_TTL_MS,
});
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data);
} catch {
homepageLogger.warn("Failed to fetch favicon", { domain });
res.status(500).json({ error: "Failed to fetch favicon" });
}
});
@@ -1,202 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
createCurrentHomepageItemRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const homepageItemsRouter = express.Router();
/**
* @openapi
* /homepage/items:
* get:
* summary: Get homepage items
* description: Returns all homepage widget items for the authenticated user.
* tags:
* - Homepage
* responses:
* 200:
* description: List of homepage items.
* 500:
* description: Failed to fetch homepage items.
*/
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const items =
await createCurrentHomepageItemRepository().listByUserId(userId);
res.json(items);
} catch (err) {
homepageLogger.error("Failed to fetch homepage items", err);
res.status(500).json({ error: "Failed to fetch homepage items" });
}
});
/**
* @openapi
* /homepage/items:
* post:
* summary: Create homepage item
* description: Creates a new homepage widget item for the authenticated user.
* tags:
* - Homepage
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - typeId
* properties:
* typeId:
* type: string
* title:
* type: string
* config:
* type: object
* responses:
* 201:
* description: Homepage item created.
* 400:
* description: Invalid data.
* 500:
* description: Failed to create homepage item.
*/
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { typeId, title, config } = req.body;
if (!typeId || typeof typeId !== "string") {
return res.status(400).json({ error: "typeId is required" });
}
try {
const created = await createCurrentHomepageItemRepository().createForUser(
userId,
{
typeId,
title: title ?? null,
config: config ? JSON.stringify(config) : "{}",
},
);
res.status(201).json(created);
} catch (err) {
homepageLogger.error("Failed to create homepage item", err);
res.status(500).json({ error: "Failed to create homepage item" });
}
});
/**
* @openapi
* /homepage/items/{id}:
* put:
* summary: Update homepage item
* description: Updates a homepage widget item.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* config:
* type: object
* responses:
* 200:
* description: Homepage item updated.
* 400:
* description: Invalid data.
* 404:
* description: Not found.
* 500:
* description: Failed to update homepage item.
*/
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(req.params.id as string);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
const { title, config } = req.body;
try {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Parameters<typeof itemRepository.updateForUser>[2] = {};
if (title !== undefined) updates.title = title;
if (config !== undefined) updates.config = JSON.stringify(config);
const updated = await itemRepository.updateForUser(userId, id, updates);
res.json(updated ?? existing);
} catch (err) {
homepageLogger.error("Failed to update homepage item", err);
res.status(500).json({ error: "Failed to update homepage item" });
}
});
/**
* @openapi
* /homepage/items/{id}:
* delete:
* summary: Delete homepage item
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Homepage item deleted.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to delete homepage item.
*/
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(req.params.id as string);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const deleted = await itemRepository.deleteForUser(userId, id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"homepageItems",
deleted.syncId,
);
}
res.json({ message: "Homepage item deleted" });
} catch (err) {
homepageLogger.error("Failed to delete homepage item", err);
res.status(500).json({ error: "Failed to delete homepage item" });
}
});
@@ -1,86 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
export const homepageLayoutRouter = express.Router();
/**
* @openapi
* /homepage/layout:
* get:
* summary: Get homepage layout
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
* tags:
* - Homepage
* responses:
* 200:
* description: Layout data or null.
* 500:
* description: Failed to fetch homepage layout.
*/
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const row =
await createCurrentHomepageLayoutRepository().findByUserId(userId);
if (!row) {
return res.json(null);
}
const parsed = JSON.parse(row.layout || "{}");
res.json({ ...row, layout: parsed });
} catch (err) {
homepageLogger.error("Failed to fetch homepage layout", err);
res.status(500).json({ error: "Failed to fetch homepage layout" });
}
});
/**
* @openapi
* /homepage/layout:
* put:
* summary: Save homepage layout
* description: Saves or updates the homepage canvas layout for the authenticated user.
* tags:
* - Homepage
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* entries:
* type: array
* pan:
* type: object
* zoom:
* type: number
* responses:
* 200:
* description: Layout saved.
* 500:
* description: Failed to save homepage layout.
*/
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const layoutData = req.body;
try {
const layoutJson = JSON.stringify(layoutData);
const now = new Date().toISOString();
const updated = await createCurrentHomepageLayoutRepository().upsertForUser(
userId,
layoutJson,
now,
);
const parsed = JSON.parse(updated.layout);
res.json({ ...updated, layout: parsed });
} catch (err) {
homepageLogger.error("Failed to save homepage layout", err);
res.status(500).json({ error: "Failed to save homepage layout" });
}
});
@@ -1,108 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
export const homepagePingRouter = express.Router();
interface PingCacheEntry {
ok: boolean;
statusCode: number | null;
latencyMs: number;
expires: number;
}
const pingCache = new Map<string, PingCacheEntry>();
const CACHE_SIZE = 200;
const FETCH_TIMEOUT_MS = 5000;
async function requestStatus(
url: string,
method: "HEAD" | "GET",
): Promise<number | null> {
const res = await safeOutboundFetch(url, {
method,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
// Discard the body without buffering it.
await res.body?.cancel().catch(() => {});
return res.status ?? null;
}
async function pingUrl(
url: string,
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
const start = performance.now();
const elapsed = () => Math.round(performance.now() - start);
try {
let code = await requestStatus(url, "HEAD");
if (code === 405) {
code = await requestStatus(url, "GET");
}
return {
ok: code !== null && code < 400,
statusCode: code,
latencyMs: elapsed(),
};
} catch {
return { ok: false, statusCode: null, latencyMs: elapsed() };
}
}
/**
* @openapi
* /homepage/ping:
* get:
* summary: Check the HTTP reachability and latency of a URL
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: ttl
* schema:
* type: integer
* description: Cache TTL in seconds (min 10)
* responses:
* 200:
* description: Ping result with ok, statusCode and latencyMs.
* 400:
* description: Invalid or missing URL.
*/
homepagePingRouter.get("/", async (req: Request, res: Response) => {
let targetUrl = req.query.url as string;
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
try {
new URL(targetUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = pingCache.get(targetUrl);
if (cached && cached.expires > Date.now()) {
return res.json({
ok: cached.ok,
statusCode: cached.statusCode,
latencyMs: cached.latencyMs,
});
}
try {
const result = await pingUrl(targetUrl);
if (pingCache.size >= CACHE_SIZE) {
const oldest = pingCache.keys().next().value;
if (oldest) pingCache.delete(oldest);
}
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
res.json(result);
} catch (err) {
homepageLogger.warn("Ping failed", { targetUrl });
res.status(500).json({ error: "Ping failed" });
}
});
@@ -1,95 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageProxyRouter = express.Router();
interface ProxyCacheEntry {
data: unknown;
expires: number;
}
const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
async function fetchJson(rawUrl: string): Promise<unknown> {
const response = await safeOutboundFetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
try {
return JSON.parse(text);
} catch {
throw new Error("Response is not valid JSON");
}
}
/**
* @openapi
* /homepage/proxy:
* get:
* summary: Proxy a JSON API URL and return the parsed response
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: ttl
* schema:
* type: integer
* description: Cache TTL in seconds (min 10, default 60)
* responses:
* 200:
* description: The JSON body returned by the target URL.
* 400:
* description: Invalid or missing URL, or non-JSON response.
* 500:
* description: Failed to fetch the target URL.
*/
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
const targetUrl = req.query.url as string;
const ttl =
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
try {
new URL(targetUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = proxyCache.get(targetUrl);
if (cached && cached.expires > Date.now()) {
return res.json(cached.data);
}
try {
const data = await fetchJson(targetUrl);
if (proxyCache.size >= CACHE_SIZE) {
const oldest = proxyCache.keys().next().value;
if (oldest) proxyCache.delete(oldest);
}
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
res.json(data);
} catch (err) {
const msg = getErrorMessage(err);
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
if (msg.includes("not valid JSON")) {
return res.status(400).json({ error: "Response is not valid JSON" });
}
res.status(500).json({ error: "Failed to fetch URL" });
}
});
@@ -1,142 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageRssRouter = express.Router();
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
interface RssItem {
title: string;
link: string;
pubDate: string | null;
description: string | null;
}
function fetchXml(url: string): Promise<string> {
return safeOutboundFetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
});
}
function parseRss(xml: string): RssItem[] {
const items: RssItem[] = [];
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
let match: RegExpExecArray | null;
const getText = (tag: string, src: string): string | null => {
const m = new RegExp(
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
"i",
).exec(src);
if (!m) return null;
return (m[1] ?? m[2]).trim();
};
const getLink = (src: string): string => {
// Self-closing <link rel="alternate" href="..." /> (BBC style)
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
if (selfClose) return selfClose[1];
// Plain text <link>url</link>
return getText("link", src) ?? "";
};
while ((match = itemRegex.exec(xml)) !== null) {
const src = match[1];
items.push({
title: getText("title", src) ?? "(no title)",
link: getLink(src),
pubDate: getText("pubDate", src) ?? getText("updated", src),
description: getText("description", src) ?? getText("summary", src),
});
if (items.length >= 50) break;
}
// Atom feed fallback
if (items.length === 0) {
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
while ((match = entryRegex.exec(xml)) !== null) {
const src = match[1];
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
items.push({
title: getText("title", src) ?? "(no title)",
link: linkMatch?.[1] ?? "",
pubDate: getText("published", src) ?? getText("updated", src),
description: getText("summary", src) ?? getText("content", src),
});
if (items.length >= 50) break;
}
}
return items;
}
/**
* @openapi
* /homepage/rss:
* get:
* summary: Proxy and parse an RSS/Atom feed
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: max
* schema:
* type: integer
* default: 10
* responses:
* 200:
* description: Array of feed items.
* 400:
* description: Invalid or missing URL.
* 500:
* description: Failed to fetch or parse the feed.
*/
homepageRssRouter.get("/", async (req: Request, res: Response) => {
const feedUrl = req.query.url as string;
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
if (!feedUrl) return res.status(400).json({ error: "url is required" });
try {
new URL(feedUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = rssCache.get(feedUrl);
if (cached && cached.expires > Date.now()) {
return res.json(cached.data.slice(0, max));
}
try {
const xml = await fetchXml(feedUrl);
const items = parseRss(xml);
if (rssCache.size >= CACHE_SIZE) {
const oldest = rssCache.keys().next().value;
if (oldest) rssCache.delete(oldest);
}
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
res.json(items.slice(0, max));
} catch {
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
res.status(500).json({ error: "Failed to fetch feed" });
}
});
@@ -1,303 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDataAccess,
}: HostAutostartRoutesDeps,
): void {
/**
* @openapi
* /host/autostart/enable:
* post:
* summary: Enable autostart for SSH configuration
* description: Enables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart enabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 404:
* description: SSH configuration not found.
* 500:
* description: Internal server error.
*/
router.post(
"/autostart/enable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
const hostRepository = createCurrentHostRepository();
const config = await hostRepository.findByIdForUser(
userId,
sshConfigId,
);
if (!config) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found",
});
}
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const endpointHosts = await hostRepository.listByUserId(userId);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword:
decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: getErrorMessage(error),
});
}
}
await hostRepository.updateForUser(userId, sshConfigId, {
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
});
res.json({
message: "AutoStart enabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/disable:
* delete:
* summary: Disable autostart for SSH configuration
* description: Disables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart disabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 500:
* description: Internal server error.
*/
router.delete(
"/autostart/disable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
});
res.json({
message: "AutoStart disabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/status:
* get:
* summary: Get autostart status
* description: Retrieves the autostart status for the user's SSH configurations.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart configurations.
* 500:
* description: Internal server error.
*/
router.get(
"/autostart/status",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = (
await createCurrentHostRepository().listByUserId(userId)
).filter((config) => config.autostartPassword || config.autostartKey);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType,
}));
res.json({
autostart_configs: statusList,
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
}
+214 -160
View File
@@ -2,6 +2,22 @@ import {
prepareHostImports,
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
changeHostOverrides,
type OverrideChange,
} from "../../hosts/defaults/overrides.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { splitDefaultKey } from "../../../types/host-defaults.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
@@ -10,9 +26,17 @@ import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { serializeWebUiConfig } from "./host-web-endpoints.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import {
applyPluginHostImportSettings,
setHostPluginEnabled,
} from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
@@ -181,6 +205,29 @@ export function registerHostBulkRoutes(
* parentHostId:
* type: integer
* nullable: true
* statusCheckEnabled:
* type: boolean
* pin:
* type: boolean
* pluginEnable:
* type: object
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
* additionalProperties:
* type: boolean
* resetDefaults:
* type: object
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Bulk update completed.
@@ -277,28 +324,8 @@ export function registerHostBulkRoutes(
simpleUpdates.folder = null;
}
}
if (typeof updates.enableTerminal === "boolean")
simpleUpdates.enableTerminal = updates.enableTerminal;
if (typeof updates.enableTunnel === "boolean")
simpleUpdates.enableTunnel = updates.enableTunnel;
if (typeof updates.enableFileManager === "boolean")
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableDocker === "boolean")
simpleUpdates.enableDocker = updates.enableDocker;
if (typeof updates.enableWebUi === "boolean") {
simpleUpdates.enableWebUi = updates.enableWebUi;
if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;
}
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.enableAiAssistant === "boolean")
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
// Disabling Proxmox is a plain flag flip; enabling is handled per-host
// below so each host can default to its own stored credential.
if (updates.enableProxmox === false)
simpleUpdates.enableProxmox = false;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
if (Object.keys(simpleUpdates).length > 0) {
await hostRepository.updateManyForUser(
@@ -307,53 +334,30 @@ export function registerHostBulkRoutes(
simpleUpdates,
);
}
if (typeof updates.statusCheckEnabled === "boolean") {
await changeHostOverrides(ownedIds, {
own: [["core", "statusCheckEnabled"]],
});
}
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
void recompute({ userIds: [userId] }).catch(() => {});
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
for (const host of ownedHosts) {
try {
const existing = host.statsConfig
? JSON.parse(host.statsConfig as string)
: {};
const merged = { ...existing, ...updates.statsConfig };
await hostRepository.updateForUser(userId, host.id, {
statsConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
// Each plugin's own host switch, by plugin id: the field its manifest
// names in contributes.settings.host.enableKey.
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
for (const [pluginId, enabled] of Object.entries(
updates.pluginEnable as Record<string, unknown>,
)) {
if (typeof enabled !== "boolean") continue;
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
errors.push(`Plugin ${pluginId} has no host switch`);
}
}
}
// Enabling Proxmox needs per-host handling: each host defaults its
// Proxmox credential to the credential already stored on that host, so
// discovery works right away without picking one by hand. Existing
// proxmoxConfig values are preserved.
if (updates.enableProxmox === true) {
for (const host of ownedHosts) {
try {
const existing = host.proxmoxConfig
? JSON.parse(host.proxmoxConfig as string)
: {};
const merged = {
defaultCredentialId:
existing.defaultCredentialId ?? host.credentialId ?? null,
windowsPatterns: existing.windowsPatterns ?? "win, windows",
dockerPatterns: existing.dockerPatterns ?? "docker",
preferredPrefixes:
existing.preferredPrefixes ?? "10., 192.168.",
autoSyncEnabled: existing.autoSyncEnabled ?? false,
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
markMissingGuests: existing.markMissingGuests ?? true,
};
await hostRepository.updateForUser(userId, host.id, {
enableProxmox: true,
proxmoxConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to enable Proxmox for host ${host.id}`);
}
}
}
const reset = readDefaultsReset(updates.resetDefaults);
if (reset) await changeHostOverrides(ownedIds, reset);
return res.json({
updated: ownedIds.length,
@@ -572,6 +576,7 @@ export function registerHostBulkRoutes(
}
}
const knownAuthTypes = listKnownAuthTypes();
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -611,20 +616,11 @@ export function registerHostBulkRoutes(
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
![
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
"tailscale",
"vault",
].includes(hostData.authType)
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
@@ -711,45 +707,18 @@ export function registerHostBulkRoutes(
port: hostData.port,
username,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
enableWebUi: hostData.enableWebUi || false,
enableProxmox: hostData.enableProxmox || false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableAiAssistant: hostData.enableAiAssistant || false,
enableCommandHistory: hostData.enableCommandHistory !== false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
defaultPath: hostData.defaultPath || "/",
sudoPassword: hostData.sudoPassword || null,
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
webUiConfig: hostData.enableWebUi
? serializeWebUiConfig(hostData.webUiConfig)
: null,
proxmoxConfig: hostData.proxmoxConfig
? JSON.stringify(hostData.proxmoxConfig)
: null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
sshOptions: hostData.sshOptions,
terminalConfig: hostData.terminalConfig,
}) ?? null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
@@ -769,9 +738,6 @@ export function registerHostBulkRoutes(
? 1
: 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
enableRdp: hostData.enableRdp ?? false,
enableVnc: hostData.enableVnc ?? false,
enableTelnet: hostData.enableTelnet ?? false,
updatedAt: new Date().toISOString(),
};
@@ -782,24 +748,6 @@ export function registerHostBulkRoutes(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.rdpDomain = hostData.rdpDomain || null;
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
sshDataObj.rdpPort = hostData.rdpPort || 3389;
sshDataObj.vncUser = hostData.vncUser || null;
sshDataObj.vncPassword = hostData.vncPassword || null;
sshDataObj.vncPort = hostData.vncPort || 5900;
sshDataObj.telnetUser = hostData.telnetUser || null;
sshDataObj.telnetPassword = hostData.telnetPassword || null;
sshDataObj.telnetPort = hostData.telnetPort || 23;
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
? JSON.stringify(hostData.guacamoleConfig)
: null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
@@ -811,15 +759,18 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
sshDataObj.security = null;
sshDataObj.ignoreCert = 0;
sshDataObj.guacamoleConfig = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
const saved = await hostRepository.updateEncryptedForUser(
userId,
@@ -828,6 +779,7 @@ export function registerHostBulkRoutes(
);
if (!saved) throw new Error("Host no longer exists");
if (exportId !== undefined) importedIds.set(exportId, existing.id);
savedHostId = existing.id;
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
@@ -836,8 +788,31 @@ export function registerHostBulkRoutes(
sshDataObj,
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
results.success++;
}
const protocolAuth = readProtocolAuthPayload(
hostData as Record<string, unknown>,
);
if (protocolAuth) {
await writeProtocolAuth(
userId,
savedHostId,
await keepUsableProtocolCredentials(protocolAuth, userId),
{ isOwner: true },
);
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
await applyDefaultsAfterHostWrite(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
@@ -991,32 +966,14 @@ export function registerHostBulkRoutes(
keyType: null,
credentialId: null,
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
enableWebUi: false,
enableProxmox: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
defaultPath: "/",
sudoPassword: null,
tunnelConnections: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
webUiConfig: null,
proxmoxConfig: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
@@ -1028,14 +985,17 @@ export function registerHostBulkRoutes(
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
updatedAt: new Date().toISOString(),
};
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
@@ -1043,10 +1003,15 @@ export function registerHostBulkRoutes(
existing.id,
sshDataObj,
);
await applyDefaultsAfterHostWrite(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await hostRepository.createEncryptedForUser(userId, sshDataObj);
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
await applyDefaultsAfterHostWrite(saved.id);
results.success++;
}
} catch (error) {
@@ -1068,3 +1033,92 @@ export function registerHostBulkRoutes(
},
);
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
/** A resetDefaults body as an override change, or null when there is none. */
export function readDefaultsReset(raw: unknown): OverrideChange | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const source = raw as Record<string, unknown>;
const strings = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
const change: OverrideChange = {
inheritAll: source.all === true,
inheritNamespaces: strings(source.namespaces),
inherit: strings(source.keys).map(splitDefaultKey),
};
return change.inheritAll ||
change.inheritNamespaces!.length > 0 ||
change.inherit!.length > 0
? change
: null;
}
@@ -1,139 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/command-history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - SSH
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch command history.
*/
router.get(
"/command-history/:hostId",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostId = parseInt(hostIdParam, 10);
if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn("Invalid userId or hostId for command history fetch", {
operation: "command_history_fetch",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const history =
await createCurrentCommandHistoryRepository().listCommandsForHost(
userId,
hostId,
);
res.json(history);
} catch (err) {
sshLogger.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
hostId,
userId,
});
res.status(500).json({ error: "Failed to fetch command history" });
}
},
);
/**
* @openapi
* /host/command-history:
* delete:
* summary: Delete command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted from history.
* 400:
* description: Invalid data.
* 500:
* description: Failed to delete command.
*/
router.delete(
"/command-history",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !command) {
sshLogger.warn("Invalid data for command history deletion", {
operation: "command_history_delete",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostId,
command,
);
res.json({ message: "Command deleted from history" });
} catch (err) {
sshLogger.error("Failed to delete command from history", err, {
operation: "command_history_delete",
hostId,
userId,
command,
});
res.status(500).json({ error: "Failed to delete command" });
}
},
);
}
@@ -0,0 +1,708 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import {
getRequestMeta,
logAudit,
getAuditUsername,
} from "../../utils/audit-logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import {
currentCatalog,
materializeHosts,
} from "../../hosts/defaults/materialize.js";
import {
getRecomputeJob,
recompute,
startRecomputeJob,
targetForScope,
} from "../../hosts/defaults/recompute.js";
import {
levelsAbove,
readLevel,
resolveAll,
resolveForEditor,
saveLevel,
validateLevelChange,
type CheckContext,
type LevelChangeInput,
} from "../../hosts/defaults/service.js";
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
import { readDefaultsReset } from "./host-bulk-routes.js";
type HostDefaultsRoutesDeps = {
authenticateJWT: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
requireAdminSettings: RequestHandler;
};
function checkContext(actorId: string, ownerId: string | null): CheckContext {
const permissions = PermissionManager.getInstance();
return {
actorId,
ownerId,
canUseCredential: async (credentialId, userId) =>
(await findUsableCredential(credentialId, userId)) !== null,
canUseHost: async (hostId, userId) =>
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
};
}
function readChange(body: unknown): LevelChangeInput {
const source =
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
return {
set:
source.set && typeof source.set === "object" && !Array.isArray(source.set)
? (source.set as Record<string, unknown>)
: {},
unset: Array.isArray(source.unset)
? source.unset.filter((key): key is string => typeof key === "string")
: [],
};
}
async function describeLevel(
scope: HostDefaultsScope,
folderOwner?: { userId: string; name: string },
) {
const { catalog } = currentCatalog();
return {
values: await readLevel(scope),
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
};
}
/** The folder a user may edit defaults for, by id. */
async function ownedFolder(userId: string, folderId: number) {
const folder =
await createCurrentHostDefaultsRepository().findFolder(folderId);
return folder && folder.userId === userId ? folder : null;
}
export function registerHostDefaultsRoutes(
router: Router,
{
authenticateJWT,
requireEditPermission,
requireDataAccess,
requireAdminSettings,
}: HostDefaultsRoutesDeps,
): void {
const save = async (
req: Request,
res: Response,
scope: HostDefaultsScope,
ownerId: string | null,
folderOwnerId?: string,
) => {
const actorId = (req as AuthenticatedRequest).userId;
const change = await validateLevelChange(
scope,
readChange(req.body),
checkContext(actorId, ownerId),
);
if (Object.keys(change.errors).length > 0) {
return res
.status(400)
.json({ error: "Some defaults were rejected", errors: change.errors });
}
await saveLevel(scope, change, actorId);
const keys = new Set([...change.set.keys(), ...change.unset]);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: actorId,
username: await getAuditUsername(actorId),
action: "update_host_defaults",
resourceType: "host_defaults",
resourceId:
scope.level === "admin"
? "admin"
: scope.level === "user"
? String(scope.userId)
: String(scope.folderId),
resourceName: scope.level,
details: JSON.stringify({ keys: [...keys] }),
ipAddress,
userAgent,
success: true,
});
const target = targetForScope(scope, folderOwnerId);
if (scope.level === "admin") {
const job = startRecomputeJob(target, keys);
return res.json({ jobId: job.id });
}
const result = await recompute(target, keys);
return res.json({ changedHosts: result.changedHostIds.length });
};
/**
* @openapi
* /host/defaults/admin:
* get:
* summary: Get the server's host defaults
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 403:
* description: Missing admin.settings.manage.
*/
router.get(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (_req: Request, res: Response) => {
try {
res.json(await describeLevel({ level: "admin" }));
} catch (error) {
databaseLogger.error("Failed to read admin host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/admin:
* put:
* summary: Update the server's host defaults
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* description: Values by "namespace.key".
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns the recompute job id.
* 400:
* description: One or more values were rejected.
* 403:
* description: Missing admin.settings.manage.
*/
router.put(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (req: Request, res: Response) => {
try {
await save(req, res, { level: "admin" }, null);
} catch (error) {
databaseLogger.error("Failed to update admin host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* get:
* summary: Get your host defaults
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
*/
router.get(
"/defaults/user",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
res.json(await describeLevel({ level: "user", userId }));
} catch (error) {
databaseLogger.error("Failed to read user host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* put:
* summary: Update your host defaults
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
*/
router.put(
"/defaults/user",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
await save(req, res, { level: "user", userId }, userId);
} catch (error) {
databaseLogger.error("Failed to update user host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* get:
* summary: Get a folder's host defaults
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 404:
* description: No such folder of yours.
*/
router.get(
"/defaults/folders/:folderId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
res.json(
await describeLevel(
{ level: "folder", folderId: folder.id, userId },
folder,
),
);
} catch (error) {
databaseLogger.error("Failed to read folder host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* put:
* summary: Update a folder's host defaults
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
* 404:
* description: No such folder of yours.
*/
router.put(
"/defaults/folders/:folderId",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
await save(
req,
res,
{ level: "folder", folderId: folder.id, userId },
userId,
userId,
);
} catch (error) {
databaseLogger.error("Failed to update folder host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders:
* post:
* summary: Get or create a folder's defaults id
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: The folder's id.
* 400:
* description: Missing folder name.
*/
router.post(
"/defaults/folders",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const name = typeof req.body?.name === "string" ? req.body.name : "";
if (!name.trim()) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const id = await createCurrentHostDefaultsRepository().ensureFolder(
userId,
name,
);
res.json({ id });
} catch (error) {
databaseLogger.error("Failed to find folder for host defaults", error);
res.status(500).json({ error: "Failed to find the folder" });
}
},
);
/**
* @openapi
* /host/defaults/preview:
* post:
* summary: Preview a host defaults change
* description: How many hosts would change if a level were saved with these values. Nothing is written.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* level:
* type: string
* enum: [admin, user, folder]
* folderId:
* type: integer
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The number of hosts that would change.
* 403:
* description: Not allowed to change that level.
*/
router.post(
"/defaults/preview",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const level = req.body?.level;
try {
let target;
let overlayUserId: string | undefined;
let folderId: number | undefined;
if (level === "admin") {
const permissions = PermissionManager.getInstance();
if (
!(await permissions.hasPermission(userId, "admin.settings.manage"))
) {
return res.status(403).json({ error: "Access denied" });
}
target = { all: true };
} else if (level === "user") {
target = { userIds: [userId] };
overlayUserId = userId;
} else if (level === "folder") {
const folder = await ownedFolder(userId, Number(req.body?.folderId));
if (!folder)
return res.status(404).json({ error: "Folder not found" });
target = { userIds: [userId] };
folderId = folder.id;
} else {
return res.status(400).json({ error: "Unknown level" });
}
const change = readChange(req.body);
const { catalog } = currentCatalog();
const set = new Map<string, unknown>();
for (const [key, value] of Object.entries(change.set ?? {})) {
const info = catalog.get(key);
if (info) set.set(key, info.normalize(value));
}
const result = await materializeHosts(target, {
dryRun: true,
overlay: {
level,
userId: overlayUserId,
folderId,
set,
unset: new Set(change.unset ?? []),
},
});
res.json({ changedHosts: result.changedHostIds.length });
} catch (error) {
databaseLogger.error("Failed to preview host defaults", error);
res.status(500).json({ error: "Failed to preview host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/resolve:
* get:
* summary: Resolve host defaults for a host
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
* tags:
* - Host Defaults
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: folder
* schema:
* type: string
* - in: query
* name: parentHostId
* schema:
* type: integer
* responses:
* 200:
* description: Resolved values with their source, keyed by "namespace.key".
* 404:
* description: No such host you can edit.
*/
router.get(
"/defaults/resolve",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
let ownerId = userId;
const hostId =
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
if (hostId !== null) {
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Host not found" });
}
const [row] = await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
});
if (!row) return res.status(404).json({ error: "Host not found" });
ownerId = row.userId;
}
const folder =
typeof req.query.folder === "string" ? req.query.folder : undefined;
const parentHostId =
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
? Number(req.query.parentHostId)
: undefined;
res.json({
values: await resolveForEditor({
ownerId,
hostId,
folder: folder === undefined ? undefined : folder || null,
parentHostId:
parentHostId === undefined || !Number.isInteger(parentHostId)
? folder !== undefined
? null
: undefined
: parentHostId,
}),
});
} catch (error) {
databaseLogger.error("Failed to resolve host defaults", error);
res.status(500).json({ error: "Failed to resolve host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/jobs/{id}:
* get:
* summary: Host defaults job status
* description: Progress of a server-wide host defaults update.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The job's status and how many hosts changed.
* 404:
* description: No such job.
*/
router.get(
"/defaults/jobs/:id",
authenticateJWT,
requireAdminSettings,
(req: Request, res: Response) => {
const job = getRecomputeJob(String(req.params.id));
if (!job) return res.status(404).json({ error: "Job not found" });
res.json(job);
},
);
/**
* @openapi
* /host/db/host/{id}/reset-defaults:
* post:
* summary: Reset a host to its defaults
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Reset.
* 400:
* description: Nothing to reset.
* 403:
* description: No edit access to the host.
*/
router.post(
"/db/host/:id/reset-defaults",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Number(req.params.id);
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const reset = readDefaultsReset(req.body);
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
try {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
if (!access.isOwner) {
// A shared editor never takes over the owner's SSH login.
reset.inherit = (reset.inherit ?? []).filter(
([namespace, key]) => !(namespace === "core" && key === "auth"),
);
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
return res.status(403).json({
error: "Only the host owner can reset the host's SSH login",
});
}
}
await changeHostOverrides([hostId], reset);
res.json({ message: "Host reset to its defaults" });
} catch (error) {
databaseLogger.error("Failed to reset host defaults", error);
res.status(500).json({ error: "Failed to reset host defaults" });
}
},
);
}
@@ -9,7 +9,6 @@ export function applyHostEnrollmentDefaults(
connectionType: "ssh",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
...hostData,
};
@@ -1,544 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/file_manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
router.get(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for recent files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for recent files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const recentFiles =
await createCurrentFileManagerBookmarkRepository().listRecentForHost(
userId,
hostId,
20,
);
res.json(recentFiles);
} catch (err) {
sshLogger.error("Failed to fetch recent files", err);
res.status(500).json({ error: "Failed to fetch recent files" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
router.post(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().upsertRecent(
userId,
{
hostId,
path,
name,
},
);
res.json({ message: "Recent file added" });
} catch (err) {
sshLogger.error("Failed to add recent file", err);
res.status(500).json({ error: "Failed to add recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* delete:
* summary: Remove recent file
* description: Removes a file from the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
router.delete(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Recent file removed" });
} catch (err) {
sshLogger.error("Failed to remove recent file", err);
res.status(500).json({ error: "Failed to remove recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* get:
* summary: Get pinned files
* description: Retrieves a list of pinned files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
router.get(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for pinned files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for pinned files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const pinnedFiles =
await createCurrentFileManagerBookmarkRepository().listPinnedForHost(
userId,
hostId,
);
res.json(pinnedFiles);
} catch (err) {
sshLogger.error("Failed to fetch pinned files", err);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* post:
* summary: Add pinned file
* description: Adds a file to the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
router.post(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createPinned(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "File already pinned" });
}
res.json({ message: "File pinned" });
} catch (err) {
sshLogger.error("Failed to pin file", err);
res.status(500).json({ error: "Failed to pin file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* delete:
* summary: Remove pinned file
* description: Removes a file from the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
router.delete(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Pinned file removed" });
} catch (err) {
sshLogger.error("Failed to remove pinned file", err);
res.status(500).json({ error: "Failed to remove pinned file" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* get:
* summary: Get shortcuts
* description: Retrieves a list of shortcuts for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
router.get(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for shortcuts fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for shortcuts fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const shortcuts =
await createCurrentFileManagerBookmarkRepository().listShortcutsForHost(
userId,
hostId,
);
res.json(shortcuts);
} catch (err) {
sshLogger.error("Failed to fetch shortcuts", err);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* post:
* summary: Add shortcut
* description: Adds a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
router.post(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createShortcut(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "Shortcut already exists" });
}
res.json({ message: "Shortcut added" });
} catch (err) {
sshLogger.error("Failed to add shortcut", err);
res.status(500).json({ error: "Failed to add shortcut" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* delete:
* summary: Remove shortcut
* description: Removes a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Shortcut removed" });
} catch (err) {
sshLogger.error("Failed to remove shortcut", err);
res.status(500).json({ error: "Failed to remove shortcut" });
}
},
);
}
@@ -1,20 +1,18 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -23,7 +21,6 @@ type HostFolderRoutesDeps = {
requireDeletePermission: RequestHandler;
requireCredentialEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
statsServerUrl: string;
};
export function registerHostFolderRoutes(
@@ -35,7 +32,6 @@ export function registerHostFolderRoutes(
requireDeletePermission,
requireCredentialEditPermission,
requireDataAccess,
statsServerUrl,
}: HostFolderRoutesDeps,
): void {
/**
@@ -98,6 +94,8 @@ export function registerHostFolderRoutes(
oldName,
newName,
);
// Subfolders change parents with a rename, so their chains do too.
void recompute({ userIds: [userId] }).catch(() => {});
res.json({
message: "Folder renamed successfully",
@@ -183,6 +181,9 @@ export function registerHostFolderRoutes(
* credentialId:
* type: integer
* nullable: true
* localOnly:
* type: boolean
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
* responses:
* 200:
* description: Folder metadata updated successfully.
@@ -198,7 +199,7 @@ export function registerHostFolderRoutes(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
const { name, color, icon, credentialId, localOnly } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
@@ -239,6 +240,21 @@ export function registerHostFolderRoutes(
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
userId,
name,
localOnly,
);
}
if (normalizedCredentialId !== undefined) {
await setFolderCredentialDefault(
userId,
folder.id,
normalizedCredentialId,
);
void recompute({ userIds: [userId] }).catch(() => {});
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -401,17 +417,7 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
await createCurrentFileManagerBookmarkRepository().deleteByHostIds(
hostIds,
);
await createCurrentTransferRecentRepository().deleteByHostIds(
hostIds,
);
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
);
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
@@ -424,50 +430,19 @@ export function registerHostFolderRoutes(
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
);
await createCurrentSessionRecordingRepository().deleteByHostIds(
hostIds,
);
}
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
"sshFolders",
folderSyncIds,
folderName,
);
try {
const axios = (await import("axios")).default;
for (const host of hostsToDelete) {
try {
await axios.post(
`${statsServerUrl}/host-deleted`,
{ hostId: host.id },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host deletion", {
operation: "folder_hosts_delete",
hostId: host.id,
error: err instanceof Error ? err.message : String(err),
});
}
pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId });
}
} catch (err) {
sshLogger.warn("Failed to notify stats server of folder deletion", {
sshLogger.warn("Failed to publish host deletion events", {
operation: "folder_hosts_delete",
folderName,
error: err instanceof Error ? err.message : String(err),
@@ -1,5 +1,11 @@
import { normalizeImportedHost } from "./host-normalizers.js";
function jumpHostId(value: unknown): unknown {
return typeof value === "string" && /^\d+$/.test(value)
? Number(value)
: value;
}
export function prepareHostImports(input: Record<string, unknown>[]) {
const hosts = input.map((value, index) => ({
host: normalizeImportedHost(value),
@@ -27,7 +33,7 @@ export function prepareHostImports(input: Record<string, unknown>[]) {
if (!Array.isArray(jumps))
throw new Error(`Host ${entry.index + 1}: jumpHosts must be an array`);
for (const jump of jumps) {
const dependency = byId.get(jump?.hostId);
const dependency = byId.get(jumpHostId(jump?.hostId));
if (!dependency)
throw new Error(
`Host ${entry.index + 1}: jump host ${jump?.hostId} is missing from the export. Re-export and include all jump hosts.`,
@@ -48,7 +54,7 @@ export function remapImportedJumpHosts(
) {
if (!Array.isArray(jumps)) return null;
return jumps.map((jump) => {
const hostId = importedIds.get(jump.hostId);
const hostId = importedIds.get(jumpHostId(jump.hostId));
if (hostId === undefined)
throw new Error(`Jump host ${jump.hostId} failed to import`);
return { hostId };
@@ -1,171 +0,0 @@
import type { Request, Response, Router } from "express";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
export function registerHostInternalRoutes(router: Router): void {
/**
* @openapi
* /host/db/host/internal:
* get:
* summary: Get internal SSH host data
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart hosts.
* 403:
* description: Forbidden.
* 500:
* description: Failed to fetch autostart SSH data.
*/
router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
const autostartHosts =
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
const result = autostartHosts
.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
return null;
}
return {
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
tags: ["autostart"],
};
})
.filter(Boolean);
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
/**
* @openapi
* /host/db/host/internal/all:
* get:
* summary: Get all internal SSH host data
* description: Returns all internal SSH host data. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of all hosts.
* 401:
* description: Invalid or missing internal authentication token.
* 500:
* description: Failed to fetch all hosts.
*/
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
const allHosts =
await createCurrentHostResolutionRepository().listAllHosts();
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
}
@@ -1,9 +1,7 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
import type { RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
@@ -95,54 +93,4 @@ export function registerHostNetworkRoutes(
}
},
);
router.post(
"/db/host/:id/wake",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
const userId = (req as AuthenticatedRequest).userId;
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host || host.userId !== userId) {
return res.status(404).json({ error: "Host not found" });
}
if (!host.macAddress || !isValidMac(host.macAddress)) {
return res
.status(400)
.json({ error: "No valid MAC address configured" });
}
await sendWakeOnLan(
host.macAddress,
host.wolBroadcastAddress ?? undefined,
);
sshLogger.info("Wake-on-LAN packet sent", {
operation: "wake_on_lan",
userId,
hostId,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Wake-on-LAN failed", error, {
operation: "wake_on_lan",
userId,
hostId,
});
res.status(500).json({
error: getErrorMessage(error, "Failed to send WoL packet"),
});
}
},
);
}
+138 -201
View File
@@ -1,5 +1,10 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { parseWebUiConfig } from "./host-web-endpoints.js";
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
import {
parseSshOptions,
type HostSshOptions,
} from "../../hosts/ssh-options.js";
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -24,12 +29,7 @@ export function applyHostKeyTypeUpdate(
}
}
const PROTOCOL_ENABLE_FIELDS = [
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
] as const;
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
values: Record<string, unknown>,
@@ -43,12 +43,15 @@ export function normalizeProtocolEnableFields(
);
}
/**
* The owner's SSH authentication, which a shared editor may not change. A
* plugin protocol's login is guarded in writeProtocolAuth instead.
*/
export const OWNER_PRIVATE_AUTH_FIELDS = {
ssh: [
"authType",
"authMethod",
"credentialId",
"vaultProfileId",
"overrideCredentialUsername",
"shareSshAuth",
"password",
@@ -57,37 +60,26 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
"keyType",
"sudoPassword",
],
rdp: [
"rdpAuthType",
"rdpCredentialId",
"rdpUser",
"rdpPassword",
"rdpDomain",
],
vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"],
telnet: [
"telnetAuthType",
"telnetCredentialId",
"telnetUser",
"telnetPassword",
],
} as const satisfies Record<AuthOverrideProtocol, readonly string[]>;
} as const;
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
"agentSocketPath",
] as const;
/** SSH options only the host's owner may change. */
export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const;
export function containsOwnerPrivateAuthUpdate(
hostData: Record<string, unknown>,
protocol: AuthOverrideProtocol,
protocol: keyof typeof OWNER_PRIVATE_AUTH_FIELDS,
): boolean {
return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) =>
Object.prototype.hasOwnProperty.call(hostData, field),
);
}
export const FOLDER_PATH_SEPARATOR = " / ";
const FOLDER_PATH_SEPARATOR = " / ";
/**
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
@@ -177,33 +169,12 @@ export type NormalizedImportedHost = Record<string, unknown> & {
credentialId?: number;
credentialAlias?: string;
pin?: unknown;
enableTerminal?: unknown;
enableTunnel?: unknown;
enableFileManager?: unknown;
enableDocker?: unknown;
enableWebUi?: unknown;
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
enableTerminalToolbar?: unknown;
enableAiAssistant?: unknown;
enableCommandHistory?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
showDockerInSidebar?: unknown;
showServerStatsInSidebar?: unknown;
defaultPath?: unknown;
sudoPassword?: unknown;
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statsConfig?: unknown;
dockerConfig?: unknown;
webUiConfig?: unknown;
proxmoxConfig?: unknown;
enableProxmoxStats?: unknown;
proxmoxStatsConfig?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
terminalConfig?: unknown;
sshOptions?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
useSocks5?: unknown;
@@ -214,40 +185,41 @@ export type NormalizedImportedHost = Record<string, unknown> & {
socks5ProxyChain?: unknown;
portKnockSequence?: unknown;
overrideCredentialUsername?: unknown;
domain?: unknown;
security?: unknown;
ignoreCert?: unknown;
guacamoleConfig?: unknown;
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
function legacyPrefix(protocolId: string): string {
return protocolId.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
}
function legacyKey(protocolId: string): string {
const prefix = legacyPrefix(protocolId);
return prefix.charAt(0).toUpperCase() + prefix.slice(1);
}
export function normalizeImportedHost(
hostData: Record<string, unknown>,
): NormalizedImportedHost {
const credentialAlias =
asString(hostData.credentialAlias) || asString(hostData.credentialName);
// A row with no connectionType names its protocol the way a 2.8 export
// did: an "enable<Protocol>" flag and a "<protocol>Port".
const flagged = listHostProtocols().find((protocol) =>
asBoolean(hostData[`enable${legacyKey(protocol.id)}`]),
);
const connectionType =
asString(hostData.connectionType) ||
(asBoolean(hostData.enableRdp)
? "rdp"
: asBoolean(hostData.enableVnc)
? "vnc"
: asBoolean(hostData.enableTelnet)
? "telnet"
: "ssh");
asString(hostData.connectionType) || flagged?.id || "ssh";
const protocol = listHostProtocols().find(
(entry) => entry.id === connectionType,
);
const port =
asPort(hostData.port) ||
(connectionType === "rdp"
? asPort(hostData.rdpPort) || 3389
: connectionType === "vnc"
? asPort(hostData.vncPort) || 5900
: connectionType === "telnet"
? asPort(hostData.telnetPort) || 23
: asPort(hostData.sshPort) || 22);
(protocol
? asPort(hostData[`${legacyPrefix(protocol.id)}Port`]) ||
protocol.defaultPort ||
22
: asPort(hostData.sshPort) || 22);
return {
...hostData,
@@ -276,33 +248,15 @@ export function normalizeImportedHost(
hostData.enableSsh === undefined
? connectionType === "ssh"
: asBoolean(hostData.enableSsh),
enableRdp:
hostData.enableRdp === undefined
? connectionType === "rdp"
: asBoolean(hostData.enableRdp),
enableVnc:
hostData.enableVnc === undefined
? connectionType === "vnc"
: asBoolean(hostData.enableVnc),
enableTelnet:
hostData.enableTelnet === undefined
? connectionType === "telnet"
: asBoolean(hostData.enableTelnet),
};
}
const SENSITIVE_FIELDS = [
"key",
"keyPassword",
"autostartKey",
"autostartKeyPassword",
"password",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
];
export function stripSensitiveFields(
@@ -318,11 +272,9 @@ export function stripSensitiveFields(
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasRdpPassword = !!host.rdpPassword;
result.hasVncPassword = !!host.vncPassword;
result.hasTelnetPassword = !!host.telnetPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -360,34 +312,11 @@ const CONNECT_LEVEL_FIELDS = new Set([
"shareSshAuth",
"authOverrides",
"connectionType",
"enableTerminal",
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableWebUi",
"webUiConfig",
"enableProxmox",
"enableProxmoxStats",
"enableTmuxMonitor",
"enableTerminalToolbar",
"enableAiAssistant",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"statusCheckEnabled",
"statusCheckInterval",
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
"sshPort",
"rdpPort",
"rdpAuthType",
"vncPort",
"telnetPort",
"defaultPath",
"scpLegacy",
"tunnelConnections",
"protocolAuth",
"jumpHosts",
"createdAt",
"updatedAt",
@@ -409,16 +338,13 @@ export function sanitizeHostForRecipient(
// can't see (or share permission on) the parent host row, so a shared
// host always renders at root rather than leaking another host's id.
delete stripped.parentHostId;
if (
stripped.terminalConfig &&
typeof stripped.terminalConfig === "object" &&
!Array.isArray(stripped.terminalConfig)
) {
const terminalConfig = {
...(stripped.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.agentSocketPath;
stripped.terminalConfig = terminalConfig;
for (const field of ["terminalConfig", "sshOptions"] as const) {
const value = stripped[field];
if (value && typeof value === "object" && !Array.isArray(value)) {
const copy = { ...(value as Record<string, unknown>) };
for (const key of OWNER_PRIVATE_SSH_OPTION_FIELDS) delete copy[key];
stripped[field] = copy;
}
}
const authOverrides =
stripped.authOverrides &&
@@ -432,6 +358,12 @@ export function sanitizeHostForRecipient(
!Array.isArray(authOverrides.ssh)
? (authOverrides.ssh as Record<string, unknown>)
: undefined;
if (stripped.protocolAuth !== undefined) {
stripped.protocolAuth = sanitizeProtocolAuthForRecipient(
stripped.protocolAuth,
permissionLevel,
);
}
if (!sshOverride?.credentialId) {
stripped.hasPassword = false;
stripped.hasKey = false;
@@ -452,11 +384,76 @@ export function sanitizeHostForRecipient(
return reduced;
}
/** Who shared a host that a linked desktop holds a read-only copy of. */
export function parseSharedSource(
value: unknown,
): { owner?: string; permissionLevel?: string } | null {
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
* core's own keys only, the SSH options. Everything else 2.8 kept there now
* belongs to plugins' host settings, which they put back through their
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
* sudoPassword, for the sanitizers to handle.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
terminalConfig?: Record<string, unknown>;
sudoPassword?: unknown;
} {
const raw = parseJsonObject(host.terminalConfig);
const sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : raw,
);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
terminalConfig:
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
...(legacySudo ? { sudoPassword: legacySudo } : {}),
};
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource);
return {
...host,
sharedSource: undefined,
localOnly: !!host.localOnly,
sharedCopy: !!shared,
...(shared
? {
isShared: true,
ownerUsername: shared.owner || undefined,
permissionLevel: shared.permissionLevel || "connect",
}
: {}),
tags:
typeof host.tags === "string"
? host.tags
@@ -465,86 +462,26 @@ export function transformHostResponse(
: [],
pin: !!host.pin,
shareSshAuth: !!host.shareSshAuth,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
enableDocker: !!host.enableDocker,
enableWebUi: !!host.enableWebUi,
enableProxmox: !!host.enableProxmox,
enableProxmoxStats: !!host.enableProxmoxStats,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
enableAiAssistant: !!host.enableAiAssistant,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
...(() => {
const ct = host.connectionType;
const rdp = !!host.enableRdp;
const vnc = !!host.enableVnc;
const tel = !!host.enableTelnet;
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
return {
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
};
})(),
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpPort: host.rdpPort ?? 3389,
vncPort: host.vncPort ?? 5900,
telnetPort: host.telnetPort ?? 23,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
rdpSecurity: host.rdpSecurity || undefined,
rdpIgnoreCert: !!host.rdpIgnoreCert,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig as string)
: undefined,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig as string)
: undefined,
dockerConfig: host.dockerConfig
? JSON.parse(host.dockerConfig as string)
: undefined,
// Guarded, unlike dockerConfig directly above: parseWebUiConfig never
// throws, so a half-written config cannot take out the whole host listing.
webUiConfig: parseWebUiConfig(host.webUiConfig),
proxmoxConfig: host.proxmoxConfig
? JSON.parse(host.proxmoxConfig as string)
: undefined,
proxmoxStatsConfig: host.proxmoxStatsConfig
? JSON.parse(host.proxmoxStatsConfig as string)
: undefined,
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
// back for 2.8 clients.
quickActions: undefined,
statusCheckEnabled:
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
statusCheckInterval:
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null,
...hostTerminalExport(host),
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
domain: host.domain || undefined,
security: host.security || undefined,
ignoreCert: !!host.ignoreCert,
guacamoleConfig: host.guacamoleConfig
? JSON.parse(host.guacamoleConfig as string)
: undefined,
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
};
}
@@ -1,841 +0,0 @@
import type { Router, Request, Response } from "express";
import { sshLogger } from "../../utils/logger.js";
import {
normalizeSelectOpParam,
renderOpksshErrorPage,
rewriteOPKSSHHtml,
} from "./opkssh-html.js";
export function registerHostOpksshRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-chooser/{requestId}:
* get:
* summary: Proxy OPKSSH provider chooser page and all related resources
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Chooser page content
* 404:
* description: Session not found
* 500:
* description: Proxy error
*/
router.use(
"/opkssh-chooser/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestIdTemp =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
sshLogger.info("OPKSSH chooser proxy request", {
operation: "opkssh_chooser_proxy_request",
requestId,
url: req.url,
originalUrl: req.originalUrl,
fullPath,
pathAfterRequestId: pathAfterRequestIdTemp,
method: req.method,
});
try {
const { getActiveAuthSession, registerOAuthState } =
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
sshLogger.error("Session not found for chooser request", {
operation: "opkssh_chooser_session_not_found",
requestId,
});
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message: "This authentication session has expired or is invalid.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
const targetPath = pathAfterRequestId || "/chooser";
if (!session.localPort || session.localPort === 0) {
sshLogger.error("OPKSSH session has no local port", {
operation: "opkssh_chooser_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Authentication Error",
message:
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
requestId,
}),
);
return;
}
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
// external OAuth provider URL. The hops we may see:
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
if (targetPath.startsWith("/select")) {
const selectaxios = (await import("axios")).default;
const rawQs = targetPath.includes("?")
? targetPath.slice(targetPath.indexOf("?"))
: "";
let qs = rawQs;
let opMappedFrom: string | undefined;
if (rawQs) {
try {
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
const rawOp = params.get("op");
if (rawOp) {
const mappedOp = normalizeSelectOpParam(
rawOp,
session.providers || [],
);
if (mappedOp !== rawOp) {
params.set("op", mappedOp);
qs = `?${params.toString()}`;
opMappedFrom = rawOp;
}
}
} catch {
/* keep rawQs if parsing fails */
}
}
const chooserHost = `127.0.0.1:${session.localPort}`;
const startUrl = `http://${chooserHost}/select/${qs}`;
sshLogger.info("Proxying OPKSSH /select", {
operation: "opkssh_select_proxy",
requestId,
targetUrl: startUrl,
opMappedFrom,
});
const isLocalHostname = (host: string): boolean => {
const bare = host.split(":")[0];
return (
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
);
};
interface UpstreamResponse {
status: number;
location?: string;
contentType: string;
body: string;
targetUrl: string;
elapsedMs: number;
}
const fetchUpstream = async (
url: string,
): Promise<UpstreamResponse> => {
const started = Date.now();
let hostHeader = chooserHost;
try {
hostHeader = new URL(url).host;
} catch {
/* fall back to chooser host */
}
const r = await selectaxios({
method: "GET",
url,
maxRedirects: 0,
validateStatus: () => true,
timeout: 10000,
responseType: "text",
transformResponse: (v) => v,
headers: { host: hostHeader },
});
const locHeader = r.headers["location"];
const location = Array.isArray(locHeader)
? locHeader[0]
: locHeader;
const ctHeader = r.headers["content-type"];
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
const contentType = typeof ctRaw === "string" ? ctRaw : "";
const body =
typeof r.data === "string" ? r.data : String(r.data ?? "");
return {
status: r.status,
location: typeof location === "string" ? location : undefined,
contentType,
body,
targetUrl: url,
elapsedMs: Date.now() - started,
};
};
const logResponse = (response: UpstreamResponse): void => {
sshLogger.info("OPKSSH /select upstream response", {
operation: "opkssh_select_upstream_response",
requestId,
targetUrl: response.targetUrl,
status: response.status,
location: response.location,
contentType: response.contentType,
elapsedMs: response.elapsedMs,
bodyPreview: response.body.slice(0, 256),
});
};
const MAX_HOPS = 4;
try {
let response = await fetchUpstream(startUrl);
logResponse(response);
for (let hop = 0; hop < MAX_HOPS; hop++) {
if (
response.status < 300 ||
response.status >= 400 ||
!response.location
) {
break;
}
const loc = response.location;
// Relative path: resolve against the current upstream.
if (loc.startsWith("/")) {
let currentHost = chooserHost;
try {
currentHost = new URL(response.targetUrl).host;
} catch {
/* keep default */
}
response = await fetchUpstream(`http://${currentHost}${loc}`);
logResponse(response);
continue;
}
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
// the port. Then redirect the BROWSER to the proxied path so that
// Set-Cookie headers from OPKSSH's /login handler reach the browser
// directly — following them server-side would swallow the cookie.
if (/^https?:\/\//i.test(loc)) {
try {
const parsed = new URL(loc);
if (isLocalHostname(parsed.host)) {
// Capture callback listener port if not yet known.
if (!session.callbackPort) {
const port = parseInt(parsed.port, 10);
if (!Number.isNaN(port)) {
session.callbackPort = port;
sshLogger.info(
"Captured OPKSSH callback listener port from /select redirect",
{
operation: "opkssh_select_callback_port_detected",
requestId,
callbackPort: port,
},
);
}
}
// Redirect browser through the chooser proxy so it can receive
// the state cookie that OPKSSH sets on /login.
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
sshLogger.info(
"Redirecting browser to OPKSSH callback listener via proxy",
{
operation: "opkssh_select_browser_redirect_to_login",
requestId,
browserPath,
callbackPort: session.callbackPort,
},
);
res.redirect(302, browserPath);
return;
}
// External OAuth provider URL — done, handled below.
break;
} catch {
break;
}
}
break;
}
const isExternalRedirect =
response.status >= 300 &&
response.status < 400 &&
!!response.location &&
/^https?:\/\//i.test(response.location) &&
(() => {
try {
return !isLocalHostname(
new URL(response.location as string).host,
);
} catch {
return false;
}
})();
if (isExternalRedirect) {
const oauthUrl = response.location as string;
try {
const parsed = new URL(oauthUrl);
const oauthState = parsed.searchParams.get("state");
if (oauthState) registerOAuthState(oauthState, requestId);
} catch {
/* already validated above */
}
sshLogger.info(
"OPKSSH /select redirecting browser to OAuth provider",
{
operation: "opkssh_select_redirect",
requestId,
oauthUrl,
},
);
res.redirect(302, oauthUrl);
return;
}
const bodyPreview = response.body.slice(0, 512);
const detailLines = [
`Upstream: ${response.targetUrl}`,
`Status: ${response.status}`,
response.location ? `Location: ${response.location}` : undefined,
`Content-Type: ${response.contentType || "(none)"}`,
`Elapsed: ${response.elapsedMs}ms`,
"",
bodyPreview
? `Body (first 512 chars):\n${bodyPreview}`
: "Body: (empty)",
].filter(Boolean) as string[];
sshLogger.error(
"OPKSSH /select did not produce an OAuth redirect",
{
operation: "opkssh_select_no_oauth_redirect",
requestId,
status: response.status,
location: response.location,
contentType: response.contentType,
bodyPreview,
},
);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to get OAuth redirect",
message:
"OPKSSH did not return an external OAuth provider URL. " +
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
"and the Termix callback path. Check the server log for the OPKSSH response body.",
details: detailLines.join("\n"),
requestId,
}),
);
} catch (err) {
sshLogger.error("Error proxying OPKSSH /select", err, {
operation: "opkssh_select_proxy_error",
requestId,
targetUrl: startUrl,
});
const errMsg = err instanceof Error ? err.message : String(err);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to reach OPKSSH service",
message:
"Termix could not connect to the local OPKSSH authentication service. " +
"The OPKSSH process may have exited or is not listening yet.",
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
requestId,
}),
);
}
return;
}
// Paths served by the callback listener, not the chooser.
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
const isCallbackListenerPath =
targetPath === "/login" ||
targetPath.startsWith("/login?") ||
targetPath === "/login-callback" ||
targetPath.startsWith("/login-callback?");
const upstreamPort =
isCallbackListenerPath && session.callbackPort
? session.callbackPort
: session.localPort;
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
sshLogger.info("Proxying to OPKSSH chooser", {
operation: "opkssh_chooser_proxy_request_to_opkssh",
requestId,
targetUrl,
upstreamPort,
targetPath,
});
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${upstreamPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
sshLogger.info("OPKSSH chooser response received", {
operation: "opkssh_chooser_proxy_response",
requestId,
statusCode: response.status,
contentType: response.headers["content-type"],
contentLength: response.headers["content-length"],
hasLocation: !!response.headers.location,
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${location}`,
);
} else {
const localhostMatch = location.match(
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
);
if (localhostMatch) {
const port = parseInt(localhostMatch[1], 10);
const path = localhostMatch[2] || "/";
if (session.callbackPort && port === session.callbackPort) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${path}`,
);
} else if (port === session.localPort) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${path}`,
);
} else {
const isCallback =
path.includes("login") || path.includes("callback");
const prefix = isCallback
? "opkssh-callback"
: "opkssh-chooser";
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
}
} else {
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
try {
const redirectUrl = new URL(location);
const oauthState = redirectUrl.searchParams.get("state");
if (oauthState) {
registerOAuthState(oauthState, requestId);
}
} catch {
// Not a valid URL, skip state capture
}
res.setHeader(key, value as string);
}
}
} else if (key.toLowerCase() === "set-cookie") {
// Rewrite cookies from OPKSSH's internal listener so they are scoped
// to the Termix proxy path instead of OPKSSH's internal path.
// The state cookie set by /login must survive to /login-callback.
const cookies = Array.isArray(value) ? value : [value as string];
const rewritten = cookies.map((cookie) => {
return cookie
.replace(/;\s*domain=[^;]*/gi, "")
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
.concat(
cookie.match(/;\s*path=/i)
? ""
: "; Path=/host/opkssh-callback/",
);
});
res.setHeader(key, rewritten);
} else {
res.setHeader(key, value as string);
}
});
// Set a cookie to correlate this browser with the requestId.
// OAuth state capture from Location headers only works for 3xx redirects;
// if OPKSSH redirects via JavaScript, the state is never registered.
// This cookie survives the OIDC round-trip and identifies the session on callback.
res.cookie("opkssh_request_id", requestId, {
path: "/host/",
httpOnly: true,
sameSite: "lax",
maxAge: 5 * 60 * 1000,
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-chooser",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error proxying OPKSSH chooser", error, {
operation: "opkssh_chooser_proxy_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "Failed to load authentication page. Please try again.",
requestId,
}),
);
}
},
);
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
* tags: [SSH]
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: No active authentication session found
* 500:
* description: Authentication failed
*/
router.get("/opkssh-callback", async (req: Request, res: Response) => {
try {
sshLogger.info("OAuth callback received", {
operation: "opkssh_static_callback_received",
host: req.headers.host,
});
const {
getUserIdFromRequest,
getActiveSessionsForUser,
getActiveAuthSession,
getRequestIdByOAuthState,
clearOAuthState,
} = await import("../../hosts/opkssh-auth.js");
const userId = await getUserIdFromRequest({
cookies: req.cookies,
headers: req.headers as Record<string, string | undefined>,
});
sshLogger.info("User ID resolved", {
operation: "opkssh_callback_user_lookup",
userId: userId || "null",
hasCookies: !!req.cookies?.jwt,
cookieKeys: Object.keys(req.cookies || {}),
});
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
[];
if (userId) {
userSessions = getActiveSessionsForUser(userId);
} else {
// No JWT cookie (e.g. OAuth redirect landed in external browser).
// Try to find the correct session via the OAuth state parameter.
const oauthState = req.query.state as string | undefined;
if (oauthState) {
const mappedRequestId = getRequestIdByOAuthState(oauthState);
if (mappedRequestId) {
const mappedSession = getActiveAuthSession(mappedRequestId);
if (mappedSession) {
userSessions = [mappedSession];
clearOAuthState(oauthState);
sshLogger.info("Resolved session via OAuth state parameter", {
operation: "opkssh_callback_state_lookup",
requestId: mappedRequestId,
});
}
}
}
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
// State capture only works for 3xx redirects; if OPKSSH redirects via
// JavaScript in the HTML, the state is never registered in the map.
if (userSessions.length === 0) {
const cookieRequestId = req.cookies?.opkssh_request_id;
if (cookieRequestId) {
const cookieSession = getActiveAuthSession(cookieRequestId);
if (cookieSession) {
userSessions = [cookieSession];
res.clearCookie("opkssh_request_id", { path: "/host/" });
sshLogger.info("Resolved session via opkssh_request_id cookie", {
operation: "opkssh_callback_cookie_lookup",
requestId: cookieRequestId,
});
}
}
}
if (userSessions.length === 0) {
sshLogger.warn(
"OAuth callback with no JWT, no matching state, and no session cookie",
{
operation: "opkssh_callback_no_session_match",
hasState: !!oauthState,
hasCookie: !!req.cookies?.opkssh_request_id,
},
);
res
.status(401)
.send("Authentication callback failed: unable to identify session");
return;
}
}
sshLogger.info("Active sessions for user", {
operation: "opkssh_callback_session_lookup",
userId,
sessionCount: userSessions.length,
sessions: userSessions.map((s) => ({
requestId: s.requestId,
status: s.status,
hasCallbackPort: !!s.callbackPort,
callbackPort: s.callbackPort,
hasLocalPort: !!s.localPort,
localPort: s.localPort,
})),
});
if (userSessions.length === 0) {
sshLogger.error("No active sessions for callback", {
operation: "opkssh_callback_no_sessions",
userId,
});
res.status(404).send("No active authentication session found");
return;
}
const session = userSessions[userSessions.length - 1];
if (!session.callbackPort) {
sshLogger.error("Session callback port not ready", {
operation: "opkssh_callback_port_not_ready",
userId,
requestId: session.requestId,
sessionStatus: session.status,
hasLocalPort: !!session.localPort,
});
res.status(503).send("OPKSSH callback listener not ready yet");
return;
}
const queryString = req.url.includes("?")
? req.url.substring(req.url.indexOf("?"))
: "";
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
sshLogger.info("Redirecting OAuth callback to dynamic route", {
operation: "opkssh_static_callback_redirect",
userId,
requestId: session.requestId,
callbackPort: session.callbackPort,
queryParams: Object.keys(req.query),
redirectUrl,
});
res.redirect(302, redirectUrl);
} catch (error) {
sshLogger.error("Error handling OPKSSH static callback", error, {
operation: "opkssh_static_callback_error",
url: req.url,
originalUrl: req.originalUrl,
});
res.status(500).send("Authentication callback failed");
}
});
/**
* @openapi
* /host/opkssh-callback/{requestId}:
* get:
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: Invalid authentication session
* 500:
* description: Authentication failed
*/
router.use(
"/opkssh-callback/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
try {
const { getActiveAuthSession } =
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message:
"Authentication session expired or invalid. Please close this window and try again.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
// (query-only or empty), prepend it.
const targetPath =
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
? `/login-callback${pathAfterRequestId}`
: pathAfterRequestId;
if (!session.callbackPort || session.callbackPort === 0) {
sshLogger.error("OPKSSH callback session has no callback port", {
operation: "opkssh_callback_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Callback Error",
message:
"OPKSSH callback listener not ready. Please try authenticating again.",
requestId,
}),
);
return;
}
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${session.callbackPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${location}`,
);
} else {
res.setHeader(key, value as string);
}
} else {
res.setHeader(key, value as string);
}
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-callback",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
operation: "opkssh_oauth_callback_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "An unexpected error occurred. Please try again.",
requestId,
}),
);
}
},
);
}
@@ -0,0 +1,407 @@
/**
* The pluginSettings map attached to a host on its way out.
*
* Without this the host editor would need one request per plugin per host,
* which is the shape that makes a list of a few hundred hosts slow. One query
* covers the whole list instead.
*
* Kept out of transformHostResponse because that function is synchronous and
* shared by every host read path. Secrets are always redacted here, so nothing
* on this path has to decrypt and the async work stays in one place.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import {
declaredFields,
findField,
resolveFieldValue,
setSetting,
} from "../../plugins/settings.js";
import {
coerceSettingValue,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/**
* Marks a host changed after one of its plugin settings did, so remote sync,
* which pulls by updated_at, carries the new value across.
*/
export async function touchHost(hostId: number): Promise<void> {
try {
const { sql } = await import("drizzle-orm");
const { runStatement } =
await import("../../utils/crypto-migration/raw-rows.js");
await runStatement(
sql`UPDATE ssh_data SET updated_at = ${new Date().toISOString()} WHERE id = ${hostId}`,
);
} catch {
// Only a sync hint; the setting itself is already saved.
}
}
/** Enabled plugins that declare host-scope settings, with their manifests. */
export function hostSettingsPlugins(): PluginManifest[] {
try {
const { loader } = getPluginRuntime();
return loader
.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => plugin.manifest)
.filter(
(manifest): manifest is PluginManifest =>
!!manifest && !!manifest.contributes?.settings?.host,
);
} catch {
// The runtime is not up in every context that renders a host.
return [];
}
}
/**
* Builds the map for a set of hosts in one query.
*
* Returns an empty map when no enabled plugin declares host settings, so the
* common case costs nothing.
*/
export async function loadHostPluginSettings(
hostIds: number[],
): Promise<Map<number, HostPluginSettings>> {
const result = new Map<number, HostPluginSettings>();
const manifests = hostSettingsPlugins();
if (manifests.length === 0 || hostIds.length === 0) return result;
let rows: PluginSettingsRecord[] = [];
try {
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
"host",
hostIds.map(String),
);
} catch (error) {
// A host list must not fail because a settings read did.
sshLogger.warn("Failed to load host plugin settings", {
operation: "host_plugin_settings",
error: error instanceof Error ? error.message : String(error),
});
return result;
}
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
for (const row of rows) {
const key = `${row.scopeId}:${row.pluginId}`;
const list = byHostAndPlugin.get(key);
if (list) list.push(row);
else byHostAndPlugin.set(key, [row]);
}
for (const hostId of hostIds) {
const perPlugin: HostPluginSettings = {};
for (const manifest of manifests) {
const fields = declaredFields(manifest, "host");
if (fields.length === 0) continue;
const stored = new Map(
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
row.key,
row,
]),
);
const values: Record<string, unknown> = {};
for (const field of fields) {
values[field.key] = await resolveFieldValue(
field,
stored.get(field.key),
{ redactSecrets: true },
);
}
perPlugin[manifest.id] = values;
}
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
}
return result;
}
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
/**
* Drops the fields a shared host's recipient may not read: a field declares
* the lowest share level that sees it with shareRead. The owner sees all.
*/
function visibleHostPluginSettings(
values: HostPluginSettings,
host: Record<string, unknown>,
): HostPluginSettings {
if (!host.isShared) return values;
const level = SHARE_LEVELS.indexOf(
typeof host.permissionLevel === "string" ? host.permissionLevel : "connect",
);
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const copy = { ...own };
for (const field of declaredFields(manifest, "host")) {
if (
field.shareRead &&
SHARE_LEVELS.indexOf(field.shareRead) > Math.max(level, 0)
) {
delete copy[field.key];
}
}
result[manifest.id] = copy;
}
return result;
}
/**
* Fields a plugin still puts on the host payload in their pre-2.9 shape, for
* clients outside Termix (Termix-Mobile) that have not moved to
* pluginSettings yet. Registered as ctx.registry.provide(
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
* values and the host, and never overwrites a field core already set. When
* both are objects the plugin's keys are added to core's, under the same
* rule.
*/
export type PluginHostPayloadLegacy = (
values: Record<string, unknown>,
host: Record<string, unknown>,
) => Record<string, unknown> | null;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function applyLegacyFields(
host: Record<string, unknown>,
values: HostPluginSettings,
): void {
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const legacy = consume<PluginHostPayloadLegacy>(
`${manifest.id}.hostPayloadLegacy`,
);
if (!legacy) continue;
try {
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
const current = host[key];
if (current === undefined || current === null) {
host[key] = value;
} else if (isPlainObject(current) && isPlainObject(value)) {
// An object core already sends (terminalConfig) gains the plugin's
// keys, still never over one core set.
host[key] = { ...value, ...current };
}
}
} catch {
// A plugin's compat shape must never break a host read.
}
}
}
/** Attaches the map to already-transformed host objects, in place. */
export function attachHostPluginSettings(
hosts: Record<string, unknown>[],
settings: Map<number, HostPluginSettings>,
): void {
if (settings.size === 0) return;
for (const host of hosts) {
const hostId = Number(host.id);
const values = settings.get(hostId);
if (!values) continue;
host.pluginSettings = visibleHostPluginSettings(values, host);
applyLegacyFields(host, host.pluginSettings as HostPluginSettings);
}
}
/**
* The single-host convenience wrapper. With a viewer, a shared host's
* personal fields come from that viewer's own defaults.
*/
export async function withHostPluginSettings(
host: Record<string, unknown>,
viewerId?: string,
): Promise<Record<string, unknown>> {
const hostId = Number(host.id);
if (!Number.isInteger(hostId)) return host;
const settings = await loadHostPluginSettings([hostId]);
if (viewerId) {
const { applyPersonalHostValues } =
await import("../../hosts/defaults/personal.js");
await applyPersonalHostValues(settings, viewerId).catch(() => {});
}
const values = settings.get(hostId);
if (!values) return host;
const result: Record<string, unknown> = {
...host,
pluginSettings: visibleHostPluginSettings(values, host),
};
applyLegacyFields(result, result.pluginSettings as HostPluginSettings);
return result;
}
/**
* Writes a set of host-scope plugin settings in one call, through the same
* path as every other settings write: an undeclared key is refused, a value
* is validated, and a secret field is encrypted.
*/
export async function writeHostPluginSettings(
manifest: PluginManifest,
hostId: number,
values: Record<string, unknown>,
): Promise<void> {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) continue;
const error = await setSetting(manifest, "host", hostId, key, value);
if (error) {
sshLogger.warn("Refused a plugin host setting on import", {
operation: "host_import_plugin_settings",
pluginId: manifest.id,
hostId,
key,
error,
});
}
}
}
/**
* Checks the host settings a ctx.hosts write carries, keyed by plugin id,
* before anything is written. A key the plugin does not declare, or a value
* its field refuses, is an error. A plugin that is not running has nowhere to
* keep them, so its entry is skipped and named in `skipped`.
*/
export function checkHostPluginSettingsInput(
input: Record<string, Record<string, unknown>>,
): {
writes: Array<{ manifest: PluginManifest; values: Record<string, unknown> }>;
skipped: string[];
errors: string[];
} {
const running = new Map(hostSettingsPlugins().map((m) => [m.id, m]));
const writes: Array<{
manifest: PluginManifest;
values: Record<string, unknown>;
}> = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const [pluginId, values] of Object.entries(input)) {
const manifest = running.get(pluginId);
if (!manifest) {
skipped.push(pluginId);
continue;
}
for (const [key, value] of Object.entries(values)) {
const field = findField(manifest, "host", key);
if (!field) {
errors.push(`${pluginId}.${key} is not a host setting it declares`);
continue;
}
const error = validateSettingValue(
field,
coerceSettingValue(field, value),
);
if (error) errors.push(`${pluginId}.${key}: ${error}`);
}
writes.push({ manifest, values });
}
return { writes, skipped, errors };
}
/**
* A plugin's own validator for the fields it accepts inline on a bulk host
* import row (host-bulk-routes.ts's Termix-JSON import path). Registered
* through ctx.registry.provide("<pluginId>.hostImportNormalizer", fn) - not
* part of the SDK's typed ctx surface, because only this one bulk-import
* path calls it and a full contract is speculative until a second caller
* needs it. Returns the fields to write (JSON-serializable, matching
* writeHostPluginSettings's input), or null to write nothing for this row.
*/
export type PluginHostImportNormalizer = (
raw: Record<string, unknown>,
) => Record<string, unknown> | null;
/**
* Runs every enabled plugin's registered import normalizer over one imported
* host row and writes whatever each one returns, so a plugin's host-scope
* settings are validated the same way on bulk import as they are anywhere
* else - instead of host-bulk-routes.ts hardcoding one plugin's shape.
*
* Best-effort per plugin: one plugin's normalizer throwing must not fail the
* whole import, so it is logged and skipped rather than propagated.
*/
export async function applyPluginHostImportSettings(
hostId: number,
raw: Record<string, unknown>,
): Promise<void> {
const exported =
raw.pluginSettings && typeof raw.pluginSettings === "object"
? (raw.pluginSettings as HostPluginSettings)
: {};
for (const manifest of hostSettingsPlugins()) {
try {
// What an export carried for this plugin, validated like any other
// write. Secrets never leave the server, so there are none to restore.
const carried = exported[manifest.id];
if (carried && typeof carried === "object") {
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in carried)) continue;
await setSetting(
manifest,
"host",
hostId,
field.key,
carried[field.key],
);
}
}
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
const values = normalizer?.(raw);
if (values) await writeHostPluginSettings(manifest, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
operation: "host_import_plugin_settings",
pluginId: manifest.id,
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Turns one plugin's host switch on or off for a set of hosts. Returns false
* when the plugin is not running or declares no enableKey.
*/
export async function setHostPluginEnabled(
pluginId: string,
hostIds: number[],
enabled: boolean,
): Promise<boolean> {
const manifest = hostSettingsPlugins().find((m) => m.id === pluginId);
const enableKey = manifest?.contributes?.settings?.host?.enableKey;
if (!manifest || !enableKey) return false;
for (const hostId of hostIds) {
await setSetting(manifest, "host", hostId, enableKey, enabled);
await touchHost(hostId);
}
const { changeHostOverrides } =
await import("../../hosts/defaults/overrides.js");
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
return true;
}
@@ -0,0 +1,259 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
DEFAULT_STATUS_INTERVAL,
GLOBAL_STATUS_INTERVAL_KEY,
hostStatusService,
type HostStatusEntry,
} from "../../hosts/status/host-status-service.js";
interface HostStatusRoutesDeps {
authenticateJWT: RequestHandler;
requireAdmin: RequestHandler;
}
/** "1,2,3" to a set of ids; undefined means the caller asked for all. */
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
}
function sessionExpired(res: Response): Response {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
export function registerHostStatusRoutes(
router: Router,
{ authenticateJWT, requireAdmin }: HostStatusRoutesDeps,
): void {
const permissionManager = PermissionManager.getInstance();
/**
* @openapi
* /host/status:
* get:
* summary: Get host statuses
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostIds
* required: false
* schema:
* type: string
* description: Comma separated ids. Only these of the user's own hosts are checked.
* responses:
* 200:
* description: A map of host ids to status entries.
* 401:
* description: Session expired - please log in again.
*/
router.get(
"/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null)
return sessionExpired(res);
const requested = parseStatusHostIds(req.query.hostIds);
const statuses = await hostStatusService.statusesFor(userId, requested);
// One batched permission check: this is polled every few seconds, and a
// per-host check made it linear in host count against the database.
const entries = [...statuses.entries()];
const allowed = await permissionManager.filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const result: Record<number, HostStatusEntry> = {};
for (const [id, entry] of entries) {
if (allowed.has(id) && (requested === null || requested.has(id))) {
result[id] = entry;
}
}
res.json(result);
},
);
/**
* @openapi
* /host/status/settings:
* get:
* summary: Get status check settings
* description: Returns the default number of seconds between status checks.
* tags:
* - SSH
* responses:
* 200:
* description: The status check settings.
* 403:
* description: Admin access required.
*/
router.get(
"/status/settings",
authenticateJWT,
requireAdmin,
async (_req: Request, res: Response) => {
const value = Number(
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
);
res.json({
statusCheckInterval:
Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL,
});
},
);
/**
* @openapi
* /host/status/settings:
* put:
* summary: Update status check settings
* description: Sets the default number of seconds between status checks and re-times every running check.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* minimum: 5
* maximum: 3600
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid interval.
* 403:
* description: Admin access required.
*/
router.put(
"/status/settings",
authenticateJWT,
requireAdmin,
async (req: Request, res: Response) => {
const seconds = Number(req.body?.statusCheckInterval);
if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) {
return res
.status(400)
.json({ error: "Status check interval must be 5 to 3600 seconds" });
}
try {
await createCurrentSettingsRepository().set(
GLOBAL_STATUS_INTERVAL_KEY,
String(seconds),
);
hostStatusService.retimeAll();
res.json({ statusCheckInterval: seconds });
} catch (error) {
sshLogger.error("Failed to save status check settings", error, {
operation: "host_status_settings",
});
res.status(500).json({ error: "Failed to save settings" });
}
},
);
/**
* @openapi
* /host/status/refresh:
* post:
* summary: Restart status checks
* description: Re-reads the user's own hosts and restarts their status checks.
* tags:
* - SSH
* responses:
* 200:
* description: Status checks restarted.
* 401:
* description: Session expired - please log in again.
*/
router.post(
"/status/refresh",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
await hostStatusService.refresh(userId);
res.json({ message: "Status checks restarted" });
},
);
/**
* @openapi
* /host/status/{id}:
* get:
* summary: Get one host's status
* description: Returns the status of one host the user can connect to.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The host's status entry.
* 400:
* description: Invalid host id.
* 404:
* description: Status not available.
*/
router.get(
"/status/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
const access = await permissionManager.canAccessHost(
userId,
id,
"connect",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Status not available" });
}
await hostStatusService.statusesFor(userId, null);
const entry =
hostStatusService.get(id) ?? (await hostStatusService.check(id));
if (!entry) {
return res.status(404).json({ error: "Status not available" });
}
res.json(entry);
},
);
}
@@ -1,34 +0,0 @@
import type { Request, Response, Router } from "express";
import { escapeHtml } from "./opkssh-html.js";
function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
/**
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
* the browser that finishes the sign-in may not be the one running Termix,
* so the request is matched to its session by the OAuth state.
*/
export function registerHostStepCaRoutes(router: Router): void {
router.get("/step-ca-callback", async (req: Request, res: Response) => {
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
const stringQuery = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await completeStepCaAuth({
state: stringQuery("state"),
code: stringQuery("code"),
error: stringQuery("error"),
error_description: stringQuery("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
@@ -0,0 +1,89 @@
/**
* How a host update writes terminal_config and ssh_options, kept apart from
* the route so it can be tested on its own.
*/
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { parseSshOptions } from "../../hosts/ssh-options.js";
import {
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
} from "./host-normalizers.js";
export function parseTerminalConfig(
value: unknown,
): Record<string, unknown> | null {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return null;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? { ...(parsed as Record<string, unknown>) }
: null;
}
/**
* terminal_config is merged into what is stored rather than replaced. The
* 2.8 keys the editor no longer sends (the terminal's look, which the
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
* editors kept there) stay until 3.0.0 drops the column. A shared editor
* never changes the owner's private keys in either JSON. Returns an error
* for a terminalConfig that cannot be read.
*/
export async function mergeStoredTerminalFields(
sshDataObj: Record<string, unknown>,
hostData: Record<string, unknown>,
hostId: number,
ownerId: string,
isOwner: boolean,
): Promise<string | null> {
const sentTerminalConfig =
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
const incoming = sentTerminalConfig
? parseTerminalConfig(hostData.terminalConfig)
: null;
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
delete sshDataObj.terminalConfig;
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
return null;
}
const stored = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
const merged = { ...storedConfig, ...(incoming ?? {}) };
if (!isOwner) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (field in storedConfig) merged[field] = storedConfig[field];
else delete merged[field];
}
}
// A sudo password saved on its own lives in the encrypted column.
if (sentSudoPassword) delete merged.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(merged);
}
if (!isOwner && sshDataObj.sshOptions !== undefined) {
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
}
sshDataObj.sshOptions = JSON.stringify(options);
}
return null;
}
@@ -1,201 +0,0 @@
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
} from "../../../types/index.js";
import type { WebEndpoint, WebUiConfig } from "../../../types/index.js";
export { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH };
const SCHEMES = new Set(["http", "https"]);
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
const RENDER_VALUES = new Set(["external", "embedded"]);
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
);
}
/**
* C0 controls and DEL. Checked by codepoint rather than by regex character
* class so no escaping subtlety can let one through: a path like
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
* browser strips the control character and then follows the authority.
*/
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Coerces a path to begin with "/", or returns null to drop the endpoint.
*
* This is THE enforcement point for a value that reaches an href and an
* iframe src -- the editor's checks are UX. Anything that could redirect the
* frame away from the endpoint is rejected rather than sanitized, because
* sanitizing invites a second, subtly different implementation elsewhere.
*/
function normalizePath(raw: unknown): string | null {
if (raw === undefined || raw === null || raw === "") return "/";
if (typeof raw !== "string") return null;
if (hasControlCharacter(raw)) return null;
const path = raw.startsWith("/") ? raw : `/${raw}`;
if (path.startsWith("//")) return null;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
if (path.includes("\\")) return null;
return path;
}
/**
* A bare host literal, or null to drop the endpoint, or undefined to leave the
* field unset (the route then falls back to loopback).
*
* The value lands both in a TCP listener and in a URL authority, so nothing
* carrying a scheme, port, path or credentials may survive.
*/
function normalizeBindHost(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
if (typeof raw !== "string") return null;
const value = raw.trim();
if (!value) return undefined;
if (value.length > 253) return null;
if (hasControlCharacter(value)) return null;
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
const HOSTNAME =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
if (IPV4.test(value)) {
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
}
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
return null;
}
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
if (!raw || typeof raw !== "object") return null;
const input = raw as Record<string, unknown>;
const id = typeof input.id === "string" ? input.id.trim() : "";
if (!id) return null;
const label = typeof input.label === "string" ? input.label.trim() : "";
if (!label) return null;
const scheme = input.scheme;
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
if (!isValidPort(input.port)) return null;
const path = normalizePath(input.path);
if (path === null) return null;
const access = input.access;
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
const render = input.render;
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
const isTunnel = access === "tunnel";
const endpoint: WebEndpoint = {
id,
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
scheme: scheme as WebEndpoint["scheme"],
port: input.port,
path,
access: access as WebEndpoint["access"],
render: render as WebEndpoint["render"],
// Meaningless for a tunnel, whose host component is loopback and already
// exempt from certificate checks. Cleared rather than carried so nothing
// downstream has to re-derive that.
ignoreCert: !isTunnel && input.ignoreCert === true,
};
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
// rejected: they describe a forward that a direct endpoint never creates.
if (isTunnel) {
const bindHost = normalizeBindHost(input.bindHost);
if (bindHost === null) return null;
if (bindHost !== undefined) endpoint.bindHost = bindHost;
if (input.localPort !== undefined && input.localPort !== null) {
if (!isValidPort(input.localPort)) return null;
endpoint.localPort = input.localPort;
}
}
return endpoint;
}
/**
* Drops any endpoint it refuses rather than rejecting the whole host -- one
* bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens
* (src/ui/lib/web-endpoint-validation.ts).
*/
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const endpoints: WebEndpoint[] = [];
for (const candidate of raw) {
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
const endpoint = normalizeEndpoint(candidate);
if (!endpoint) continue;
if (seen.has(endpoint.id)) continue;
seen.add(endpoint.id);
endpoints.push(endpoint);
}
return endpoints;
}
/**
* Never throws. A malformed stored value yields an empty endpoint list, so a
* half-written config cannot take out the whole host listing -- which is what
* dockerConfig's bare JSON.parse does today.
*/
export function parseWebUiConfig(raw: unknown): WebUiConfig {
if (raw === undefined || raw === null) return { endpoints: [] };
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return { endpoints: [] };
}
}
if (!value || typeof value !== "object") return { endpoints: [] };
return {
endpoints: normalizeWebEndpoints(
(value as { endpoints?: unknown }).endpoints,
),
};
}
/** Null when nothing survives normalization, so the column is cleared. */
export function serializeWebUiConfig(config: unknown): string | null {
const endpoints = normalizeWebEndpoints(
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
);
if (endpoints.length === 0) return null;
return JSON.stringify({ endpoints });
}
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,31 @@
const VALID_ACTION_TYPES = [
"copy",
"paste",
"sendControlCode",
"sendText",
"runSnippet",
"nextTab",
"previousTab",
"openCommandPalette",
];
import {
CORE_KEYBINDING_ACTIONS,
validateKeybindingActionParams,
type PluginKeybindingActionContribution,
} from "@termix/plugin-sdk/manifest";
export interface DeclaredKeybindingAction extends PluginKeybindingActionContribution {
pluginId: string;
}
let declaredActions: () => DeclaredKeybindingAction[] = () => [];
/** Set by the plugin runtime: every installed plugin's declared actions. */
export function setKeybindingActionSource(
source: () => DeclaredKeybindingAction[],
): void {
declaredActions = source;
}
/** The declaration a saved action type answers to, if a plugin made one. */
export function findKeybindingAction(
type: string,
): DeclaredKeybindingAction | undefined {
return declaredActions().find((action) => action.id === type);
}
const ACTION_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
const MAX_LOOSE_VALUE = 65_536;
export function isValidKeyCombo(combo: unknown): boolean {
return (
@@ -22,22 +40,32 @@ export function isValidKeyCombo(combo: unknown): boolean {
);
}
/**
* A saved action. The shell's own types take no parameters; a type a plugin
* declared is checked against its declaration, whether or not the plugin is
* running. A type nobody declares (its plugin was removed) is only checked
* loosely, so the rest of the list can still be saved, and whichever plugin
* claims it later ignores what it cannot use.
*/
export function isValidKeybindingAction(action: unknown): boolean {
if (!action || typeof action !== "object") return false;
const type = (action as { type?: unknown }).type;
if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
if (!action || typeof action !== "object" || Array.isArray(action)) {
return false;
if (type === "sendText") {
return typeof (action as { text?: unknown }).text === "string";
}
if (type === "sendControlCode") {
const code = (action as { controlCode?: unknown }).controlCode;
return typeof code === "string" && /^[a-zA-Z]$/.test(code);
const record = action as Record<string, unknown>;
const type = record.type;
if (typeof type !== "string" || !ACTION_TYPE_PATTERN.test(type)) {
return false;
}
if (type === "runSnippet") {
return typeof (action as { snippetId?: unknown }).snippetId === "string";
}
return true;
if (CORE_KEYBINDING_ACTIONS.includes(type)) return true;
const declared = findKeybindingAction(type);
if (declared) return validateKeybindingActionParams(record, declared);
return Object.entries(record).every(
([, value]) =>
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
(typeof value === "string" && value.length <= MAX_LOOSE_VALUE),
);
}
export function isValidKeybinding(entry: unknown): boolean {
@@ -1,409 +0,0 @@
import type { Router } from "express";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
import ldap from "ldapjs";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const authManager = AuthManager.getInstance();
function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
function createLDAPClient(
host: string,
port: number,
useTLS: boolean,
): ldap.Client {
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function ldapBind(
client: ldap.Client,
dn: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
client.bind(dn, password, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function ldapSearch(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function ldapUnbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
/* best effort */
}
}
export function registerLDAPAuthRoutes(router: Router): void {
/**
* @openapi
* /users/ldap/login:
* post:
* summary: LDAP login
* description: Authenticates a user against an LDAP server.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* providerId:
* type: integer
* username:
* type: string
* password:
* type: string
* rememberMe:
* type: boolean
* responses:
* 200:
* description: Login successful.
* 400:
* description: Missing fields.
* 401:
* description: Invalid credentials.
* 403:
* description: User not allowed.
* 404:
* description: Provider not found.
*/
router.post("/ldap/login", async (req, res) => {
const { providerId, username, password, rememberMe } = req.body as {
providerId: number;
username: string;
password: string;
rememberMe?: boolean;
};
if (!providerId || !username || !password) {
return res
.status(400)
.json({ error: "providerId, username, and password are required" });
}
try {
const provider =
await createCurrentSsoProviderRepository().findById(providerId);
if (!provider || provider.type !== "ldap" || !provider.enabled) {
return res.status(404).json({ error: "LDAP provider not found" });
}
} catch (err) {
authLogger.error("Failed to load LDAP provider", err);
return res.status(500).json({ error: "Failed to load LDAP provider" });
}
const providerResult = await loadProviderConfig(providerId);
if (!providerResult) {
return res.status(404).json({ error: "LDAP provider not found" });
}
const config = providerResult.config as unknown as LDAPProviderConfig;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
return res.status(500).json({ error: "LDAP provider is misconfigured" });
}
const serviceClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const attrList = [
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
];
const entries = await ldapSearch(
serviceClient,
config.userSearchBase,
filter,
attrList,
);
if (entries.length === 0) {
authLogger.warn("LDAP user not found", {
operation: "ldap_login",
username,
});
return res.status(401).json({ error: "Invalid username or password" });
}
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const getAttr = (key: string): string => {
const attr = userEntry.attributes.find((a) => a.type === key);
return attr
? Array.isArray(attr.values)
? attr.values[0]
: String(attr.values)
: "";
};
const ldapIdentifier =
getAttr(config.usernameAttribute || "uid") || username;
const displayName =
getAttr(config.displayNameAttribute || "cn") || username;
const email = getAttr("mail") || getAttr("email") || "";
if (config.allowedUsers) {
if (
!isOIDCUserAllowed(
config.allowedUsers,
ldapIdentifier,
email || undefined,
)
) {
authLogger.warn("LDAP user not in allowed list", {
operation: "ldap_user_not_allowed",
ldapIdentifier,
});
return res.status(403).json({ error: "User not allowed" });
}
}
// Re-bind with user's own credentials to verify password
const userClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(userClient, userDN, password);
} catch {
authLogger.warn("LDAP bind failed - wrong password", {
operation: "ldap_login",
ldapIdentifier,
});
return res.status(401).json({ error: "Invalid username or password" });
} finally {
ldapUnbind(userClient);
}
// Admin group check
let isAdmin = false;
if (config.adminGroup && config.groupSearchBase) {
try {
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
const groupEntries = await ldapSearch(
serviceClient,
config.groupSearchBase,
groupFilter,
["cn", "dn"],
);
isAdmin = groupEntries.some((g) => {
const cn = g.attributes.find((a) => a.type === "cn");
const cnVal = cn
? Array.isArray(cn.values)
? cn.values[0]
: String(cn.values)
: "";
return (
cnVal === config.adminGroup ||
g.dn.toString() === config.adminGroup
);
});
} catch (groupErr) {
authLogger.warn("LDAP group check failed", {
operation: "ldap_group_check",
error: groupErr,
});
}
}
ldapUnbind(serviceClient);
const deviceInfo = parseUserAgent(req);
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
const userRepository = createCurrentUserRepository();
let existingUser =
await userRepository.findByOidcIdentifier(oidcIdentifier);
let userId: string;
if (!existingUser) {
let autoProvision = false;
try {
autoProvision = await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
false,
);
} catch {
/* */
}
if (!autoProvision)
autoProvision =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
const isFirst = (await userRepository.countAll()) === 0;
if (!isFirst && !autoProvision) {
return res.status(403).json({ error: "Registration is disabled" });
}
userId = nanoid();
const createdUser = await userRepository.createFirstSsoUser({
id: userId,
username: displayName,
passwordHash: "",
isAdmin,
isOidc: true,
oidcIdentifier,
ssoProviderId: providerId,
});
const isFirstFinal = createdUser.isFirstUser;
try {
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
await createCurrentRoleRepository().assignRoleNameToUser({
userId,
roleName: defaultRoleName,
grantedBy: userId,
});
} catch {
/* */
}
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(userId, sessionDurationMs);
} catch (encryptionError) {
await userRepository.delete(userId);
authLogger.error(
"Failed to setup LDAP user encryption",
encryptionError,
);
return res
.status(500)
.json({ error: "Failed to setup user security" });
}
existingUser = await userRepository.findById(userId);
if (!existingUser) {
throw new Error("Created LDAP user could not be loaded");
}
} else {
userId = existingUser.id;
// Sync admin status from group membership
if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) {
existingUser =
(await userRepository.update(userId, { isAdmin })) ?? existingUser;
try {
const newRoleName = isAdmin ? "admin" : "user";
const oldRoleName = isAdmin ? "user" : "admin";
await createCurrentRoleRepository().switchUserRoleName({
userId,
addRoleName: newRoleName,
removeRoleName: oldRoleName,
grantedBy: userId,
});
} catch {
/* non-fatal */
}
}
// Update display name if not dual-auth
const isDualAuth =
existingUser.passwordHash && existingUser.passwordHash.trim() !== "";
if (!isDualAuth && existingUser.username !== displayName) {
existingUser =
(await userRepository.update(userId, { username: displayName })) ??
existingUser;
}
}
const userRecord = existingUser;
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch {
/* */
}
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: rememberMe || false,
});
authLogger.success("LDAP login successful", {
operation: "ldap_login_complete",
userId: userRecord.id,
username: userRecord.username,
});
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: rememberMe
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json({ success: true, message: "Login successful" });
} catch (err) {
ldapUnbind(serviceClient);
authLogger.error("LDAP login failed", err);
return res.status(500).json({ error: "LDAP authentication failed" });
}
});
}
@@ -1,177 +0,0 @@
import express from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { createCurrentNetworkTopologyRepository } from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /network-topology:
* get:
* summary: Get network topology for authenticated user
* description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists.
* tags:
* - Network Topology
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Network topology retrieved successfully
* content:
* application/json:
* schema:
* type: object
* nullable: true
* properties:
* nodes:
* type: array
* description: Array of graph nodes (hosts and groups)
* items:
* type: object
* properties:
* data:
* type: object
* description: Node data including id, label, type, etc.
* position:
* type: object
* description: Node position coordinates
* properties:
* x:
* type: number
* y:
* type: number
* edges:
* type: array
* description: Array of graph edges (connections)
* items:
* type: object
* properties:
* data:
* type: object
* description: Edge data including source and target node ids
* 401:
* description: User not authenticated
* 500:
* description: Server error
*/
router.get(
"/",
authenticateJWT,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "User not authenticated" });
}
const record =
await createCurrentNetworkTopologyRepository().findByUserId(userId);
if (record) {
const topologyStr = record.topology;
const topology = topologyStr ? JSON.parse(topologyStr) : null;
return res.json(topology);
} else {
return res.json(null);
}
} catch (error) {
databaseLogger.error("Failed to fetch network topology", error, {
operation: "network_topology_fetch",
userId: (req as AuthenticatedRequest).userId,
});
return res.status(500).json({
error: "Failed to fetch network topology",
details: (error as Error).message,
});
}
},
);
/**
* @openapi
* /network-topology:
* post:
* summary: Save network topology for authenticated user
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
* tags:
* - Network Topology
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - topology
* properties:
* topology:
* type: object
* description: Network topology data containing nodes and edges
* properties:
* nodes:
* type: array
* description: Array of graph nodes (hosts and groups)
* edges:
* type: array
* description: Array of graph edges (connections)
* responses:
* 200:
* description: Topology saved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Invalid topology data
* 401:
* description: User not authenticated
* 500:
* description: Server error
*/
router.post(
"/",
authenticateJWT,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "User not authenticated" });
}
const { topology } = req.body;
if (!topology) {
return res.status(400).json({ error: "Topology data is required" });
}
const topologyStr =
typeof topology === "string" ? topology : JSON.stringify(topology);
await createCurrentNetworkTopologyRepository().upsertForUser(
userId,
topologyStr,
);
return res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to save network topology", error, {
operation: "network_topology_save",
userId: (req as AuthenticatedRequest).userId,
});
return res.status(500).json({
error: "Failed to save network topology",
details: (error as Error).message,
});
}
},
);
export default router;
+41 -78
View File
@@ -2,12 +2,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
getCurrentSettingValue,
createCurrentOpenTabRepository,
createCurrentSessionShareRepository,
} from "../repositories/factory.js";
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
import { createCurrentOpenTabRepository } from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -26,27 +22,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
*/
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
}
} catch {
// DB not available, use default
}
return DEFAULT_TAB_TTL_MINUTES * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
stats: "host-metrics",
};
function normalizeTabType(tabType: string): string {
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
@@ -57,9 +36,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
userId,
cutoff,
);
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
return res.json(tabs);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
@@ -275,18 +252,44 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
/**
* @openapi
* /open-tabs/active-sessions:
* /open-tabs/session-timeout:
* get:
* summary: Get all active backend sessions for the current user
* description: >
* Returns live terminal sessions from the session manager, both sessions the
* caller owns and SSH sessions shared to the caller by another user (via
* an in-app session share). Used by the Active Connections panel and tab restore logic.
* summary: Get how long a detached session is kept
* description: The terminal's idle timeout for detached sessions, which is also how long a saved open tab lives. 30 while the terminal is off.
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of active sessions (own and shared-with-me).
* description: The timeout in minutes.
* content:
* application/json:
* schema:
* type: object
* properties:
* minutes:
* type: integer
*/
router.get(
"/session-timeout",
authenticateJWT,
(_req: Request, res: Response) => {
res.json({ minutes: getTabTtlMs() / 60_000 });
},
);
/**
* @openapi
* /open-tabs/active-sessions:
* get:
* summary: Get all active backend sessions for the current user
* description: >
* Returns the live terminal sessions the caller owns. Used by the
* Active Connections panel and tab restore logic.
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of the caller's active sessions.
* content:
* application/json:
* schema:
@@ -306,17 +309,6 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* createdAt:
* type: number
* isOwnSession:
* type: boolean
* sharedByUsername:
* type: string
* nullable: true
* permissionLevel:
* type: string
* nullable: true
* shareId:
* type: string
* nullable: true
*/
router.get(
"/active-sessions",
@@ -324,45 +316,16 @@ router.get(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const ownSessions = sessionManager.getUserSessions(userId);
const ownSessions = liveTerminalSessions.listForUser(userId);
const result = ownSessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
tabInstanceId: s.tabInstanceId,
isConnected: s.isConnected,
createdAt: s.createdAt,
isOwnSession: true,
sharedByUsername: null as string | null,
permissionLevel: null as string | null,
shareId: null as string | null,
}));
const sharedWithMe =
await createCurrentSessionShareRepository().findSharesTargetingUser(
userId,
);
for (const share of sharedWithMe) {
if (share.protocol !== "ssh") continue;
const sharedSession = sessionManager.getSession(share.sessionId);
if (!sharedSession || !sharedSession.isConnected) continue;
result.push({
sessionId: sharedSession.id,
hostId: sharedSession.hostId,
hostName: sharedSession.hostName,
tabInstanceId:
sharedSession.attachedTabInstanceId ??
sharedSession.tabInstanceId ??
null,
isConnected: sharedSession.isConnected,
createdAt: sharedSession.createdAt,
isOwnSession: false,
sharedByUsername: share.ownerUsername,
permissionLevel: share.permissionLevel,
shareId: share.id,
});
}
return res.json(result);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, {
-280
View File
@@ -1,280 +0,0 @@
import { sshLogger } from "../../utils/logger.js";
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
// `alias` field in config.yml. We need the same mapping so we can normalize any
// `op=` query param we receive (which can be alias, issuer with protocol, or
// issuer without protocol depending on client version) to what OPKSSH expects.
function opksshIssuerToName(issuer: string): string | null {
if (!issuer) return null;
const withScheme =
issuer.startsWith("http://") || issuer.startsWith("https://")
? issuer
: `https://${issuer}`;
if (withScheme.startsWith("https://accounts.google.com")) return "google";
if (withScheme.startsWith("https://login.microsoftonline.com"))
return "azure";
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
if (withScheme.startsWith("https://")) {
const host = withScheme.slice("https://".length).split("/")[0];
return host || null;
}
return null;
}
export function normalizeSelectOpParam(
rawOp: string,
providers: Array<{ alias: string; issuer: string }>,
): string {
if (!rawOp) return rawOp;
const knownNames = new Set(
providers
.map((p) => opksshIssuerToName(p.issuer))
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
if (knownNames.has(rawOp)) return rawOp;
const derivedFromRaw = opksshIssuerToName(rawOp);
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
const matchByAlias = providers.find((p) => p.alias === rawOp);
if (matchByAlias) {
const name = opksshIssuerToName(matchByAlias.issuer);
if (name) return name;
}
return rawOp;
}
type OpksshErrorPageOptions = {
title: string;
heading: string;
message: string;
details?: string;
requestId?: string;
statusCode?: number;
};
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
const title = escapeHtml(opts.title);
const heading = escapeHtml(opts.heading);
const message = escapeHtml(opts.message);
const detailsBlock = opts.details
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
: "";
const requestIdBlock = opts.requestId
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
: "";
return `<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #18181b;
color: #fafafa;
padding: 1rem;
}
.container {
text-align: center;
background: #27272a;
padding: 3rem 2rem;
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 720px;
width: 100%;
}
h1 {
color: #fafafa;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
p {
color: #9ca3af;
font-size: 0.95rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
p + p { margin-top: 0.5rem; }
.details {
color: #d4d4d8;
text-align: left;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.45;
margin-top: 1.25rem;
padding: 0.875rem 1rem;
background: #0f0f11;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.request-id {
color: #6b7280;
font-size: 0.75rem;
margin-top: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>${heading}</h1>
<p>${message}</p>
${detailsBlock}
${requestIdBlock}
</div>
</body>
</html>`;
}
export function rewriteOPKSSHHtml(
html: string,
requestId: string,
routePrefix: "opkssh-chooser" | "opkssh-callback",
): string {
const basePath = `/host/${routePrefix}/${requestId}`;
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
const attrPatterns = ["action", "href", "src", "formaction"];
for (const attr of attrPatterns) {
html = html.replace(
new RegExp(`${attr}="(/[^"]*)`, "g"),
`${attr}="${basePath}$1`,
);
html = html.replace(
new RegExp(`${attr}='(/[^']*)`, "g"),
`${attr}='${basePath}$1`,
);
}
for (const attr of ["href", "action", "src", "formaction"]) {
html = html.replace(
new RegExp(
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
"g",
),
`${attr}="${basePath}/$1`,
);
}
html = html.replace(
new RegExp(
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
html = html.replace(
new RegExp(
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
"gi",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
const baseTag = `<base href="${basePath}/">`;
if (html.includes("<base")) {
sshLogger.info("Replacing existing base tag", {
operation: "opkssh_html_rewrite_base_tag",
requestId,
basePath,
});
html = html.replace(/<base[^>]*>/i, baseTag);
} else if (html.includes("<head>")) {
sshLogger.info("Inserting base tag into head", {
operation: "opkssh_html_rewrite_base_tag_insert",
requestId,
basePath,
});
html = html.replace(/<head>/i, `<head>${baseTag}`);
} else {
sshLogger.warn("No <head> tag found, wrapping HTML", {
operation: "opkssh_html_rewrite_no_head",
requestId,
htmlLength: html.length,
htmlPreview: html.substring(0, 200),
});
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
}
sshLogger.info("HTML rewrite complete", {
operation: "opkssh_html_rewrite_complete",
requestId,
routePrefix,
hasBaseTag: html.includes("<base href="),
staticAssetCount: (html.match(/\/static\//g) || []).length,
});
return html;
}
+106 -23
View File
@@ -1,31 +1,36 @@
// Mounted at /plugin-api. Every installed plugin that exposes backend routes
// registers a sub-router here under its own plugin id, and this dispatcher
// forwards matching requests to it. Nothing registers yet, this only proves
// the path resolves end to end (nginx -> Express -> 404) ahead of the plugin
// loader landing.
// Mounted at /plugin-api. Every plugin that serves HTTP registers a router
// through ctx.http.router(), and this dispatcher forwards requests to it by
// plugin id. The routers themselves live in src/backend/plugins/http.ts, which
// also owns the middleware stack in front of them: auth, the actor, the
// enabled check, body limits and the error wrapper.
//
// WebSocket note: plugin WS traffic does NOT get its own port from the
// 30001-30006 range. It rides the existing WS servers (e.g. the terminal or
// tunnel WS servers) using a named-channel envelope, the same way other
// features multiplex over a shared connection. This is a settled decision,
// not a placeholder, so it should not need revisiting when plugin WS support
// is implemented.
// The dispatcher stays deliberately thin. It resolves a plugin id to a router
// and nothing else, so there is no place here for a check keyed on a
// particular plugin.
//
// WebSocket note: plugin sockets do NOT ride this router, and they do not get
// their own port either. They are served at /plugin-ws/<id>/<path> from the
// main server's upgrade event by src/backend/plugins/ws.ts. (An earlier
// comment here described a named-channel envelope multiplexed over the
// existing WS servers; A4 replaced that with the /plugin-ws prefix.)
import express, { type Request, type Response, type Router } from "express";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter, isPluginInstalled } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
const router = express.Router();
const activePluginRouters = new Map<string, Router>();
/**
* @openapi
* /plugin-api/{pluginId}/{path}:
* get:
* summary: Dispatch a request to an installed plugin's backend router
* description: >
* Forwards the request to the sub-router registered by the plugin with
* the given id. Returns 404 if the plugin is not installed or not
* Forwards the request to the router the plugin with the given id
* registered through ctx.http.router(). Returns 404 if the plugin is not
* installed or serves no routes, and 503 if it is installed but not
* currently running. All HTTP methods are dispatched the same way.
* tags:
* - Plugins
@@ -35,26 +40,104 @@ const activePluginRouters = new Map<string, Router>();
* required: true
* schema:
* type: string
* - in: path
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Response from the plugin's router.
* 401:
* description: Authentication required.
* 404:
* description: Plugin not installed or not running.
* description: No plugin with this id is installed.
* 503:
* description: Plugin is installed but not running.
*/
router.use("/:pluginId", (req: Request, res: Response, next) => {
const pluginId = String(req.params.pluginId);
const pluginRouter = activePluginRouters.get(pluginId);
const pluginRouter = getPluginRouter(pluginId);
if (!pluginRouter) {
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "Plugin not installed or not running" });
notServing(res, pluginId);
return;
}
pluginRouter(req, res, next);
});
/** 503 for an installed plugin that is off, 404 for an unknown id. */
function notServing(res: Response, pluginId: string): void {
if (isPluginInstalled(pluginId)) {
res.status(503).json({ error: "This feature is not available", pluginId });
return;
}
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "This feature is not available" });
}
/**
* Mounts the dispatcher. No auth in front of it: each plugin router runs
* core auth itself and skips it only for the paths it declared public, so a
* webhook or OIDC callback a plugin serves can actually be reached.
*/
export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
export interface ActiveLegacyRoutes {
id: string;
legacyPaths: string[];
legacyRedirects: PluginLegacyRedirect[];
}
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
/**
* Serves the old URLs plugins declare in contributes.http. A legacyPaths
* entry, under "/<plugin id>/", runs through that plugin's router with the
* prefix removed. A legacyRedirects entry redirects to the plugin's own URL,
* keeping the rest of the path and the query. Mounted after every core route,
* so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => ActiveLegacyRoutes[],
): void {
app.use((req: Request, res: Response, next) => {
for (const plugin of listActive()) {
const redirect = plugin.legacyRedirects.find((entry) =>
under(req.path, entry.from),
);
if (redirect) {
const rest = req.path.slice(redirect.from.length);
const query = req.originalUrl.indexOf("?");
res.redirect(
redirect.status ?? 307,
`${getRequestBasePath(req)}/plugin-api/${plugin.id}${redirect.to}${rest}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`,
);
return;
}
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
notServing(res, plugin.id);
return;
}
req.url = req.url.slice(plugin.id.length + 1) || "/";
pluginRouter(req, res, next);
return;
}
next();
});
}
export default router;
File diff suppressed because it is too large. Load diff
@@ -1,44 +0,0 @@
// Pure decision: which auth settings an imported Proxmox guest inherits.
//
// The frontend carries a parallel copy in
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported import bug, so both are kept behaviourally
// identical and each is unit-tested against the same matrix.
export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): {
authType: string;
credentialId: number | null;
overrideCredentialUsername: number;
} {
// An explicit special auth type (none/opkssh/tailscale/vault/…) wins.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!["password", "key"].includes(defaultAuthType)
) {
return {
authType: defaultAuthType,
credentialId: null,
overrideCredentialUsername: 0,
};
}
// A credential (configured default OR inherited from the source host) is a
// concrete auth source -> use it, even when defaultAuthType is the
// "password"/"key" default.
if (credentialId) {
return {
authType: "credential",
credentialId,
overrideCredentialUsername: 0,
};
}
return {
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
};
}
@@ -1,17 +0,0 @@
export function parseProxmoxJumpHosts(raw: unknown): unknown[] | null {
if (!raw) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw !== "string") return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export function serializeProxmoxJumpHosts(raw: unknown): string | null {
const parsed = parseProxmoxJumpHosts(raw);
return parsed ? JSON.stringify(parsed) : null;
}
File diff suppressed because it is too large. Load diff
+87 -380
View File
@@ -8,7 +8,7 @@ import {
getAuditUsername,
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
@@ -30,7 +30,6 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -672,6 +671,49 @@ router.delete(
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
* patch:
* summary: Change a host access rule
* description: Updates the permission level or the expiry of one share. Requires the right to manage sharing on the host.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: path
* name: accessId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* nullable: true
* description: Hours from now until the share expires. Null removes the expiry.
* responses:
* 200:
* description: The updated rule.
* 400:
* description: Invalid id or nothing to change.
* 403:
* description: Not allowed to manage sharing on this host.
* 404:
* description: No such rule.
*/
router.patch(
"/host/:id/access/:accessId",
authenticateJWT,
@@ -1242,6 +1284,48 @@ router.get(
},
);
/**
* @openapi
* /rbac/permissions/me:
* get:
* summary: Get the current user's effective role permissions
* description: >
* Returns the permission strings granted to the authenticated user
* through their roles, plus whether they are an admin. The frontend uses
* this to hide UI a user could not use. It is not a security boundary:
* every protected route checks again server side.
* tags:
* - RBAC
* responses:
* 200:
* description: The current user's permissions.
* 401:
* description: Not authenticated.
*/
router.get(
"/permissions/me",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
// isAdmin travels with the list because hasPermission() falls back to it
// on the deny path. Without it the UI would hide things from an admin
// that the server would allow.
const [permissions, isAdmin] = await Promise.all([
permissionManager.getUserPermissions(userId),
permissionManager.isAdmin(userId),
]);
res.json({ permissions, isAdmin });
} catch (error) {
databaseLogger.error("Failed to get current user permissions", error, {
operation: "get_my_permissions",
userId,
});
res.status(500).json({ error: "Failed to get permissions" });
}
},
);
/**
* @openapi
* /rbac/roles/{id}:
@@ -1899,389 +1983,12 @@ router.delete(
},
);
// SNIPPET SHARING
/**
* @openapi
* /rbac/snippet/{id}/share:
* post:
* summary: Share a snippet
* description: Shares a snippet with a user or role.
* tags:
* - RBAC
*/
router.post(
"/snippet/:id/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const {
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
}
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
}
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
if (targetType === "user") {
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
} else {
const targetRole =
await createCurrentRoleRepository().findRoleById(targetRoleId);
if (!targetRole) {
return res.status(404).json({ error: "Target role not found" });
}
}
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const accessGrant =
await createCurrentRbacAccessRepository().upsertSnippetAccess({
snippetId,
grantedBy: userId,
expiresAt,
...(targetType === "user"
? { targetType: "user" as const, targetUserId: targetUserId! }
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
});
if (!accessGrant.created) {
return res.json({
success: true,
message: "Snippet access updated",
expiresAt,
});
}
databaseLogger.success("Snippet shared successfully", {
operation: "rbac_snippet_share",
userId,
});
res.json({
success: true,
message: `Snippet shared successfully with ${targetType}`,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to share snippet", error, {
operation: "share_snippet",
userId,
});
res.status(500).json({ error: "Failed to share snippet" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access/{accessId}:
* delete:
* summary: Revoke snippet access
* description: Revokes a user's or role's access to a snippet.
* tags:
* - RBAC
*/
/**
* @openapi
* /rbac/snippet-folder/share:
* post:
* summary: Share every snippet in a folder
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared.
* 404:
* description: A target was not found.
*/
router.post(
"/snippet-folder/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder, durationHours } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
try {
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const snippetsInFolder =
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
for (const snippet of snippetsInFolder) {
for (const target of targets) {
if (target.type === "user" && target.id === userId) continue;
await rbacAccessRepository.upsertSnippetAccess({
snippetId: snippet.id,
grantedBy: userId,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
}
}
databaseLogger.success("Snippet folder shared successfully", {
operation: "rbac_snippet_folder_share",
userId,
folder,
snippetsShared: snippetsInFolder.length,
targets: targets.length,
});
res.json({
success: true,
expiresAt,
snippetsShared: snippetsInFolder.length,
});
} catch (error) {
databaseLogger.error("Failed to share snippet folder", error, {
operation: "share_snippet_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share snippet folder" });
}
},
);
router.delete(
"/snippet/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const snippetId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(snippetId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
await createCurrentRbacAccessRepository().revokeSnippetAccess(
accessId,
snippetId,
);
res.json({ success: true, message: "Snippet access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke snippet access", error, {
operation: "revoke_snippet_access",
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access:
* get:
* summary: Get snippet access list
* description: Retrieves the list of users and roles with access to a snippet.
* tags:
* - RBAC
*/
router.get(
"/snippet/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
const accessList =
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
res.json({ accessList });
} catch (error) {
databaseLogger.error("Failed to get snippet access list", error, {
operation: "get_snippet_access_list",
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* @openapi
* /rbac/shared-snippets:
* get:
* summary: Get shared snippets
* description: Retrieves snippets shared with the current user.
* tags:
* - RBAC
*/
router.get(
"/shared-snippets",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedSnippets =
await createCurrentRbacAccessRepository().listSharedSnippets(
userId,
roleIds,
);
res.json({ sharedSnippets });
} catch (error) {
databaseLogger.error("Failed to get shared snippets", error, {
operation: "get_shared_snippets",
userId,
});
res.status(500).json({ error: "Failed to get shared snippets" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
* put:
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported.
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
* security:
* - bearerAuth: []
@@ -1,278 +0,0 @@
import crypto from "crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { authLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { testConnectSource } from "../../utils/onepassword-connect.js";
import { readSecretSourcePrivateAllowlist } from "../../utils/secret-source-egress.js";
import { clearExternalSecretCache } from "../../hosts/external-secrets.js";
import { createCurrentSecretSourceRepository } from "../repositories/factory.js";
import {
toPublicSecretSource,
type SecretSourceRecord,
} from "../repositories/secret-source-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const permissionManager = PermissionManager.getInstance();
const KINDS = ["onepassword-connect"] as const;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validBaseUrl(raw: unknown): string | null {
if (!isNonEmptyString(raw)) return null;
try {
const url = new URL(raw.trim());
if (!["http:", "https:"].includes(url.protocol)) return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
async function loadOwned(
id: string,
userId: string,
res: Response,
): Promise<SecretSourceRecord | null> {
const source = await createCurrentSecretSourceRepository().findById(id);
if (!source) {
res.status(404).json({ error: "Secret source not found" });
return null;
}
if (source.userId !== userId) {
res.status(403).json({ error: "Only the owner can change this source" });
return null;
}
return source;
}
/**
* @openapi
* /secret-sources:
* get:
* summary: List secret sources visible to the caller (own + shared)
* tags:
* - Secret Sources
*/
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const rows =
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
res.json({
sources: rows.map((row) => ({
...toPublicSecretSource(row),
owned: row.userId === userId,
})),
});
} catch (error) {
authLogger.error("Failed to list secret sources", error);
res.status(500).json({ error: "Failed to list secret sources" });
}
},
);
/**
* @openapi
* /secret-sources:
* post:
* summary: Create a secret source (1Password Connect)
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
* tags:
* - Secret Sources
*/
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const {
name,
kind = "onepassword-connect",
baseUrl,
token,
shared,
} = req.body ?? {};
const url = validBaseUrl(baseUrl);
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
return res.status(400).json({
error: "name, a valid http(s) baseUrl and token are required",
});
}
if (!KINDS.includes(kind)) {
return res.status(400).json({ error: "Unsupported secret source kind" });
}
if (shared === true && !(await permissionManager.isAdmin(userId))) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
try {
const created = await createCurrentSecretSourceRepository().create({
id: crypto.randomUUID(),
userId,
name: name.trim(),
kind,
baseUrl: url,
token: token.trim(),
shared: shared === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_create",
resourceType: "secret_source",
resourceId: created.id,
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.json({ source: { ...toPublicSecretSource(created), owned: true } });
} catch (error) {
authLogger.error("Failed to create secret source", error);
res.status(500).json({ error: "Failed to create secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* put:
* summary: Update a secret source (owner only; omit token to keep it)
* tags:
* - Secret Sources
*/
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const { name, baseUrl, token, shared } = req.body ?? {};
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
if (baseUrl !== undefined && !url) {
return res.status(400).json({ error: "baseUrl must be http(s)" });
}
if (
shared === true &&
!source.shared &&
!(await permissionManager.isAdmin(userId))
) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
await createCurrentSecretSourceRepository().update(source, {
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
...(url ? { baseUrl: url } : {}),
...(isNonEmptyString(token) ? { token: token.trim() } : {}),
...(typeof shared === "boolean" ? { shared } : {}),
});
clearExternalSecretCache();
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to update secret source", error);
res.status(500).json({ error: "Failed to update secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* delete:
* summary: Delete a secret source (owner only)
* tags:
* - Secret Sources
*/
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.delete"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
await createCurrentSecretSourceRepository().deleteById(source.id);
clearExternalSecretCache();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_delete",
resourceType: "secret_source",
resourceId: source.id,
resourceName: source.name,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete secret source", error);
res.status(500).json({ error: "Failed to delete secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}/test:
* post:
* summary: Check that the source is reachable and the token is accepted
* tags:
* - Secret Sources
*/
router.post(
"/:id/test",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const repository = createCurrentSecretSourceRepository();
const source = await repository.findById(String(req.params.id));
if (!source || (source.userId !== userId && !source.shared)) {
return res.status(404).json({ error: "Secret source not found" });
}
const vaults = await testConnectSource({
baseUrl: source.baseUrl,
token: repository.decryptToken(source),
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
});
res.json({ ok: true, vaults });
} catch (error) {
res.status(200).json({ ok: false, error: getErrorMessage(error) });
}
},
);
export default router;
@@ -1,16 +0,0 @@
export function normalizeServiceLinkUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
? trimmed
: `http://${trimmed}`;
}
export function isValidServiceLinkUrl(value: string): boolean {
try {
const parsed = new URL(normalizeServiceLinkUrl(value));
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
@@ -1,362 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
10,
);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const old = await sessionRecordingRepository.listPathsOlderThan(cutoff);
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await sessionRecordingRepository.deleteById(row.id);
}
if (old.length > 0) {
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
operation: "session_log_prune",
count: old.length,
});
}
} catch (err) {
apiLogger.warn("Failed to prune old session logs", {
operation: "session_log_prune_error",
error: err instanceof Error ? err.message : String(err),
});
}
}
// Run prune once at startup, then every 24 hours
pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000).unref();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /session_logs:
* get:
* summary: List session logs
* description: Returns all terminal session recordings for the authenticated user.
* tags:
* - Session Logs
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session logs.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentSessionRecordingRepository().listByUserIdWithHost(
userId,
);
const records = rows.map((row) => {
let sizeBytes: number | null = null;
if (row.recordingPath) {
try {
sizeBytes = fs.statSync(row.recordingPath).size;
} catch {
// file may have been removed
}
}
return { ...row, sizeBytes };
});
res.json({ logs: records });
} catch (error) {
apiLogger.error("Failed to fetch session logs", error, {
operation: "session_logs_list",
userId,
});
res.status(500).json({ error: "Failed to fetch session logs" });
}
});
router.get(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
res.json({ retentionDays: getRetentionDays() });
},
);
router.put(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
* get:
* summary: Get session log metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to fetch session log.
*/
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await createCurrentSessionRecordingRepository().findByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
userId,
id,
});
res.status(500).json({ error: "Failed to fetch session log" });
}
});
/**
* @openapi
* /session_logs/{id}/content:
* get:
* summary: Get session log content
* description: Returns the raw text content of a session log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw log text.
* 403:
* description: Not authorized.
* 404:
* description: Session log or file not found.
* 500:
* description: Failed to read session log.
*/
router.get(
"/:id/content",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row =
await createCurrentSessionRecordingRepository().findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
if (!isAllowedRecordingPath(resolvedPath)) {
return res.status(403).json({ error: "Forbidden" });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath);
const format =
(row as { format?: string | null }).format ??
(row.recordingPath?.endsWith(".cast") ? "asciicast" : "text");
const contentType =
format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
userId,
id,
});
res.status(500).json({ error: "Failed to read session log" });
}
},
);
/**
* @openapi
* /session_logs/{id}:
* delete:
* summary: Delete session log
* description: Deletes a session recording and its log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session log deleted.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to delete session log.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const row = await sessionRecordingRepository.findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
await sessionRecordingRepository.deleteForUser(userId, id);
if (filePath) {
const resolvedPath = path.resolve(filePath);
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
apiLogger.error("Failed to delete session log", error, {
operation: "session_log_delete",
userId,
id,
});
res.status(500).json({ error: "Failed to delete session log" });
}
});
export default router;
@@ -1,82 +0,0 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
@@ -1,33 +0,0 @@
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
type SnippetReorderRequestBody = {
snippets?: unknown;
updates?: unknown;
};
export function extractSnippetReorderUpdates(
body: unknown,
): SnippetReorderUpdate[] | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as SnippetReorderRequestBody;
// Keep accepting the legacy `updates` key so older clients do not break
// while the web and desktop helpers converge on `snippets`.
const snippetsUpdates = Array.isArray(payload.snippets)
? payload.snippets
: Array.isArray(payload.updates)
? payload.updates
: null;
if (!snippetsUpdates) {
return null;
}
return snippetsUpdates as SnippetReorderUpdate[];
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
import express, { type Request, type Response } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
export interface SshAuthProviderSummary {
type: string;
labelKey: string;
descriptionKey?: string;
pluginId: string;
fields: unknown[];
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect: boolean;
available: boolean;
/** Set when the type belongs to a plugin that is disabled or missing. */
missingPlugin?: { id: string; name: string };
}
/**
* A plugin names its keys relative to its own locales, so they are sent
* qualified with its namespace. Core never has to hold a plugin's strings.
*/
function qualifyKey(
pluginId: string,
key: string | undefined,
): string | undefined {
if (!key || pluginId === "core" || key.includes(":")) return key;
return `${pluginId}:${key}`;
}
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
ensureCoreSshAuthProviders();
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
(provider) => ({
type: provider.type,
labelKey: qualifyKey(provider.pluginId, provider.labelKey) ?? "",
descriptionKey: qualifyKey(provider.pluginId, provider.descriptionKey),
pluginId: provider.pluginId,
fields: provider.fields ?? [],
credentialType: !!provider.credentialType,
needsUserInteraction: !!provider.needsUserInteraction,
supportsBackground: provider.supportsBackground !== false,
quickConnect: !!provider.quickConnect,
available: true,
}),
);
const known = new Set(summaries.map((summary) => summary.type));
for (const owner of listSshAuthTypeOwners()) {
if (known.has(owner.type)) continue;
summaries.push({
type: owner.type,
labelKey: "",
pluginId: owner.pluginId,
fields: [],
credentialType: false,
needsUserInteraction: false,
supportsBackground: false,
quickConnect: false,
available: false,
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
});
}
return summaries;
}
/**
* @openapi
* /ssh-auth/providers:
* get:
* summary: List SSH auth types
* description: Every SSH auth type the server can connect with, from core and from plugins, plus types declared by plugins that are disabled so the host editor can explain them.
* tags:
* - SSH Auth
* responses:
* 200:
* description: Auth type list.
* 401:
* description: Not authenticated.
*/
router.get("/providers", authenticateJWT, (_req: Request, res: Response) => {
res.json({ providers: summarizeSshAuthProviders() });
});
export default router;
@@ -1,145 +0,0 @@
import crypto from "crypto";
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
// Implemented in pure Node so we never shell out or move private keys to disk.
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
const ED25519 = "ssh-ed25519";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
const SSH2_CERT_TYPE_USER = 1;
// Standard login extensions OpenSSH grants by default; must be name-sorted.
const DEFAULT_EXTENSIONS = [
"permit-X11-forwarding",
"permit-agent-forwarding",
"permit-port-forwarding",
"permit-pty",
"permit-user-rc",
];
// --- SSH wire-format primitives -------------------------------------------
function sshString(value: Buffer | string): Buffer {
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
function sshUint32(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0, 0);
return b;
}
function sshUint64(n: bigint): Buffer {
const b = Buffer.alloc(8);
b.writeBigUInt64BE(n, 0);
return b;
}
function ed25519PublicBlob(raw32: Buffer): Buffer {
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
}
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
export function ed25519RawFromLine(line: string): Buffer | null {
const parts = line.trim().split(/\s+/);
if (parts[0] !== ED25519 || !parts[1]) return null;
let blob: Buffer;
try {
blob = Buffer.from(parts[1], "base64");
} catch {
return null;
}
try {
let off = 0;
const typeLen = blob.readUInt32BE(off);
off += 4;
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
off += typeLen;
const keyLen = blob.readUInt32BE(off);
off += 4;
const pk = blob.subarray(off, off + keyLen);
return pk.length === 32 ? pk : null;
} catch {
return null;
}
}
// --- CA generation ---------------------------------------------------------
export interface GeneratedCa {
publicKeyLine: string; // "ssh-ed25519 <base64>"
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
}
export function generateCa(): GeneratedCa {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
const raw = Buffer.from(jwk.x, "base64url");
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
const privateKeyPem = privateKey
.export({ format: "pem", type: "pkcs8" })
.toString();
return { publicKeyLine, privateKeyPem };
}
// --- Certificate signing ---------------------------------------------------
export interface SignCertOptions {
userPublicKeyLine: string; // ed25519 public key to certify
caPrivateKeyPem: string;
caPublicKeyLine: string;
keyId: string;
principals: string[]; // empty = valid for all usernames
validAfter: number; // unix seconds
validBefore: number; // unix seconds
serial?: bigint;
}
/**
* Sign an ed25519 user public key into an OpenSSH user certificate.
* Returns the certificate line, or null if the inputs aren't ed25519.
*/
export function signUserCertificate(opts: SignCertOptions): string | null {
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
if (!pk || !caRaw) return null;
const signatureKey = ed25519PublicBlob(caRaw);
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
const extensions = Buffer.concat(
[...DEFAULT_EXTENSIONS]
.sort()
.map((name) => Buffer.concat([sshString(name), sshString("")])),
);
// Everything up to (not including) the signature — this is what gets signed.
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)), // nonce
sshString(pk),
sshUint64(opts.serial ?? 0n),
sshUint32(SSH2_CERT_TYPE_USER),
sshString(opts.keyId),
sshString(principals),
sshUint64(BigInt(opts.validAfter)),
sshUint64(BigInt(opts.validBefore)),
sshString(Buffer.alloc(0)), // critical options (none)
sshString(extensions),
sshString(Buffer.alloc(0)), // reserved
sshString(signatureKey),
]);
const caKey = crypto.createPrivateKey({
key: opts.caPrivateKeyPem,
format: "pem",
type: "pkcs8",
});
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
const cert = Buffer.concat([body, sshString(signature)]);
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
}
@@ -1,486 +0,0 @@
import type {
AuthenticatedRequest,
OIDCProviderConfig,
} from "../../../types/index.js";
import type { Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
import {
getOIDCConfigFromEnv,
isOIDCEnvOverrideEnabled,
} from "./user-oidc-utils.js";
import {
decryptSsoConfigSecrets,
encryptSsoConfigSecrets,
} from "../../utils/system-secret-crypto.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
function isOidcLike(type: SSOProviderType): boolean {
return type === "oidc" || type === "github" || type === "google";
}
export function isValidOidcIssuer(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!/\/userinfo\/?$/i.test(url.pathname)
);
} catch {
return false;
}
}
const authManager = AuthManager.getInstance();
/**
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
* userId and the values must be readable during login, before anyone is
* authenticated. They are encrypted with the system key rather than a user DEK.
* Values written by the previous base64 scheme still decode, and are upgraded
* the next time the provider is saved.
*/
async function decryptProviderConfig(
configJson: string,
_userId: string,
): Promise<Record<string, unknown>> {
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch {
return {};
}
return decryptSsoConfigSecrets(config);
}
async function encryptProviderConfig(
config: Record<string, unknown>,
_userId: string,
_providerId: string,
): Promise<string> {
return JSON.stringify(await encryptSsoConfigSecrets(config));
}
function applyProviderDefaults(
type: SSOProviderType,
config: Partial<OIDCProviderConfig>,
): Partial<OIDCProviderConfig> {
if (type === "github") {
return {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://github.com",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
userinfo_url: "https://api.github.com/user",
...config,
};
}
if (type === "google") {
return {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
...config,
};
}
return config;
}
export function registerSSOProviderRoutes(router: Router): void {
const requireAdmin = authManager.createAdminMiddleware();
/**
* @openapi
* /users/sso-providers:
* get:
* summary: List enabled SSO providers (public)
* description: Returns public info for all enabled SSO providers for the login page.
* tags:
* - SSO
* responses:
* 200:
* description: Array of public SSO provider objects.
*/
router.get("/sso-providers", async (_req, res) => {
try {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return res.json([
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
]);
}
const providers =
await createCurrentSsoProviderRepository().listEnabledPublic();
// If no DB providers exist, synthesize one from env vars so SSO login
// remains available when configured purely via environment variables.
if (providers.length === 0) {
if (envConfig) {
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
}
}
res.json(providers);
} catch (err) {
authLogger.error("Failed to list SSO providers", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers/admin:
* get:
* summary: List all SSO providers (admin)
* description: Returns full SSO provider list with decrypted configs for the admin panel.
* tags:
* - SSO
* responses:
* 200:
* description: Array of full SSO provider objects with decrypted config.
*/
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await createCurrentSsoProviderRepository().listAll();
const result = await Promise.all(
rows.map(async (row) => ({
...row,
config: await decryptProviderConfig(row.config, userId),
})),
);
res.json(result);
} catch (err) {
authLogger.error("Failed to list SSO providers (admin)", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers:
* post:
* summary: Create SSO provider
* description: Creates a new SSO provider configuration.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 201:
* description: Provider created.
* 400:
* description: Validation error.
*/
router.post("/sso-providers", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const {
name,
type,
enabled = true,
displayOrder = 0,
config: rawConfig = {},
} = req.body as {
name: string;
type: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "Provider name is required" });
}
const validTypes: SSOProviderType[] = [
"oidc",
"ldap",
"github",
"google",
];
if (!validTypes.includes(type)) {
return res.status(400).json({ error: "Invalid provider type" });
}
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
const configWithDefaults =
type === "github" || type === "google"
? applyProviderDefaults(
type,
rawConfig as Partial<OIDCProviderConfig>,
)
: rawConfig;
if (type === "oidc" || type === "github" || type === "google") {
const c = configWithDefaults as Partial<OIDCProviderConfig>;
const missing = [
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
].filter((f) => !c[f as keyof OIDCProviderConfig]);
if (missing.length > 0 && type === "oidc") {
return res.status(400).json({
error: `Missing required OIDC fields: ${missing.join(", ")}`,
});
}
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
return res.status(400).json({
error:
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
});
}
if (
(type === "github" || type === "google") &&
(!c.client_id || !c.client_secret)
) {
return res
.status(400)
.json({ error: "Client ID and Client Secret are required" });
}
}
if (type === "ldap") {
const c = configWithDefaults as Record<string, unknown>;
const missing = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
].filter((f) => !c[f]);
if (missing.length > 0) {
return res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
}
}
const tempId = `new-${Date.now()}`;
const encryptedConfig = await encryptProviderConfig(
configWithDefaults as Record<string, unknown>,
userId,
tempId,
);
const inserted = await createCurrentSsoProviderRepository().create({
name: name.trim(),
type,
enabled,
displayOrder,
config: encryptedConfig,
});
authLogger.info("SSO provider created", {
operation: "sso_provider_create",
userId,
type,
providerId: inserted.id,
});
res.status(201).json({
...inserted,
config: await decryptProviderConfig(inserted.config, userId),
});
} catch (err) {
authLogger.error("Failed to create SSO provider", err);
res.status(500).json({ error: "Failed to create SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* put:
* summary: Update SSO provider
* description: Updates an existing SSO provider configuration.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider updated.
* 404:
* description: Provider not found.
*/
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
const {
name,
type,
enabled,
displayOrder,
config: rawConfig,
} = req.body as {
name?: string;
type?: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
const effectiveType = type ?? (existing.type as SSOProviderType);
const effectiveEnabled = enabled ?? existing.enabled;
if (
isTrustedProxyAuthEnabled() &&
effectiveEnabled &&
isOidcLike(effectiveType)
) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
let encryptedConfig = existing.config;
if (rawConfig !== undefined) {
const existingDecrypted = await decryptProviderConfig(
existing.config,
userId,
);
const mergedConfig = {
...JSON.parse(
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
),
...rawConfig,
};
if (
isOidcLike(effectiveType) &&
mergedConfig.issuer_url &&
!isValidOidcIssuer(mergedConfig.issuer_url)
) {
return res.status(400).json({
error:
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
});
}
encryptedConfig = await encryptProviderConfig(
mergedConfig,
userId,
String(providerId),
);
}
const updated = await providerRepository.update(providerId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(displayOrder !== undefined ? { displayOrder } : {}),
config: encryptedConfig,
updatedAt: new Date().toISOString(),
});
if (!updated) {
return res.status(404).json({ error: "SSO provider not found" });
}
authLogger.info("SSO provider updated", {
operation: "sso_provider_update",
userId,
providerId,
});
res.json({
...updated,
config: await decryptProviderConfig(updated.config, userId),
});
} catch (err) {
authLogger.error("Failed to update SSO provider", err);
res.status(500).json({ error: "Failed to update SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* delete:
* summary: Delete SSO provider
* description: Deletes an SSO provider. Blocked if users are associated.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider deleted.
* 409:
* description: Users are associated with this provider.
* 404:
* description: Provider not found.
*/
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
const associatedUserCount =
await providerRepository.countUsersByProviderId(providerId);
if (associatedUserCount > 0) {
return res.status(409).json({
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
});
}
await providerRepository.delete(providerId);
authLogger.info("SSO provider deleted", {
operation: "sso_provider_delete",
userId,
providerId,
});
res.json({ message: "SSO provider deleted" });
} catch (err) {
authLogger.error("Failed to delete SSO provider", err);
res.status(500).json({ error: "Failed to delete SSO provider" });
}
});
}
export { decryptProviderConfig, encryptProviderConfig };
@@ -1,137 +0,0 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles";
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
const CREDENTIAL_REFERENCE: SyncReference = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
};
const HOST_REFERENCES: SyncReference[] = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
];
export function orderSyncRows(
entityType: SyncEntityType,
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
if (entityType !== "hosts") return rows;
const bySyncId = new Map(
rows
.filter((row) => typeof row.syncId === "string")
.map((row) => [row.syncId as string, row]),
);
const ordered: Record<string, unknown>[] = [];
const visited = new Set<Record<string, unknown>>();
const visiting = new Set<Record<string, unknown>>();
const visit = (row: Record<string, unknown>) => {
if (visited.has(row)) return;
if (visiting.has(row)) return;
visiting.add(row);
const parentSyncId = row.parentHostSyncId;
if (typeof parentSyncId === "string") {
const parent = bySyncId.get(parentSyncId);
if (parent) visit(parent);
}
visiting.delete(row);
visited.add(row);
ordered.push(row);
};
rows.forEach(visit);
return ordered;
}
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
hosts: HOST_REFERENCES,
sshFolders: [CREDENTIAL_REFERENCE],
};
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
-549
View File
@@ -1,549 +0,0 @@
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
deserializeSyncReferences,
orderSyncRows,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import { validateParentHostId } from "./host-parent-validation.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
// Encrypted tables need DataCrypto to translate between the wire payload
// (plaintext) and the stored row (encrypted). Everything else is stored
// and synced as-is.
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
interface EntityConfig {
table:
| typeof hosts
| typeof sshCredentials
| typeof sshFolders
| typeof snippets
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems
| typeof userPreferences;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
singleton?: boolean;
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
hosts: {
table: hosts,
readOnlyFields: ["connectionOrigin"],
},
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
sshFolders: { table: sshFolders, readOnlyFields: [] },
snippets: { table: snippets, readOnlyFields: [] },
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
userPreferences: {
table: userPreferences,
readOnlyFields: ["storageMode"],
singleton: true,
},
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
/**
* Locates the stored row a sync payload corresponds to.
*
* Read and write have to agree on this. A singleton entity is keyed on its
* owner rather than a sync id, and `user_preferences` — the only singleton —
* has no `id` column at all, so an update cannot fall back to one: `table.id`
* is undefined there and drizzle emits `WHERE = ?`.
*/
export function locateSyncRow(
entityType: SyncEntityType,
userId: string,
syncId: string,
): SQL {
const { table, singleton } = ENTITY_CONFIG[entityType];
if (singleton) {
return eq(table.userId, userId);
}
return and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
)!;
}
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
if (entityType === "hosts") {
const [row] = await context.drizzle
.select({ syncId: hosts.syncId })
.from(hosts)
.where(and(eq(hosts.id, id), eq(hosts.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ syncId: sshCredentials.syncId })
.from(sshCredentials)
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
const [row] = await context.drizzle
.select({ syncId: vaultProfiles.syncId })
.from(vaultProfiles)
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
if (entityType === "hosts") {
const [row] = await context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
.limit(1);
return row?.id ?? null;
}
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.syncId, syncId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return row?.id ?? null;
}
const [row] = await context.drizzle
.select({ id: vaultProfiles.id })
.from(vaultProfiles)
.where(
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
)
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = ENTITY_CONFIG[entityType];
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
);
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const decrypted = await Promise.all(
rows.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: orderSyncRows(entityType, decrypted) });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(locateSyncRow(entityType, userId, syncId));
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
if (
entityType === "hosts" &&
typeof resolvedPayload.parentHostId === "number"
) {
const parentError = await validateParentHostId(
userId,
typeof existing?.id === "number" ? existing.id : null,
resolvedPayload.parentHostId,
);
if (parentError) return res.status(400).json({ error: parentError });
}
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await context.drizzle
.update(table as typeof hosts)
.set(encryptedPayload)
.where(locateRow)
.returning();
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await context.drizzle
.insert(table as typeof hosts)
.values(
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
)
.returning();
resultRow = insertedRows[0] as Record<string, unknown>;
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
export default router;
@@ -1,128 +0,0 @@
import {
Router,
type RequestHandler,
type Router as ExpressRouter,
} from "express";
import { apiLogger } from "../../utils/logger.js";
import { fetchWithProxy } from "../../utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
interface TailscaleDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
interface TailscaleAPIDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
nodeId?: string;
}
const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
const router = Router();
function normalizeApiBase(raw: string | null): string {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
return trimmed || DEFAULT_TAILSCALE_API_BASE;
}
export function registerTailscaleRoutes(
app: ExpressRouter,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /tailscale/devices:
* get:
* summary: List Tailscale devices
* description: Returns the list of devices in the configured tailnet using the stored API key.
* tags:
* - Tailscale
* responses:
* 200:
* description: List of tailnet devices.
* content:
* application/json:
* schema:
* type: object
* properties:
* devices:
* type: array
* items:
* type: object
* 500:
* description: Failed to fetch Tailscale devices.
*/
router.get("/devices", authenticateJWT, async (_req, res) => {
try {
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
await settingsRepo.get("tailscale_api_base_url"),
);
const url = `${apiBase}/tailnet/-/devices?fields=all`;
const response = await fetchWithProxy(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
});
if (!response.ok) {
apiLogger.warn("Tailscale API returned non-OK status", {
operation: "tailscale_devices",
status: response.status,
});
if (response.status === 401 || response.status === 403) {
return res.status(401).json({
error: "Invalid Tailscale API key",
devices: [],
hasApiKey: true,
});
}
return res.status(502).json({
error: "Tailscale API error",
devices: [],
hasApiKey: true,
});
}
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
id: d.id,
name: d.name,
hostname: d.hostname,
addresses: d.addresses ?? [],
os: d.os,
lastSeen: d.lastSeen,
}));
res.json({ devices, hasApiKey: true });
} catch (err) {
apiLogger.error("Failed to fetch Tailscale devices", err, {
operation: "tailscale_devices",
});
res.status(500).json({
error: "Failed to fetch Tailscale devices",
devices: [],
hasApiKey: true,
});
}
});
app.use("/tailscale", router);
}
@@ -1,264 +0,0 @@
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
/**
* Terminal image storage modes.
*
* - `local`: always write to the backend's mapped local storage. Deterministic:
* never falls back to the remote SFTP path.
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
* Deterministic: never falls back to local storage.
* - `auto`: pick by capability only — remote SFTP when a connected terminal
* session exists, local storage otherwise. Configuration never influences
* this choice.
*/
export const TERMINAL_IMAGE_STORAGE_MODES = [
"auto",
"local",
"remote-sftp",
] as const;
export type TerminalImageStorageMode =
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
/** Absolute path on the Termix backend where local-mode files are written. */
localDir: string;
/**
* Absolute path handed to the terminal agent in local mode. This is the
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
* the only path ever exposed to callers.
*/
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
/** Both localDir and hostPath were explicitly configured and must be probed. */
localMappingConfigured: boolean;
}
/** Settings-table keys. Persisted values always win over environment. */
export const TERMINAL_IMAGE_STORAGE_KEYS = {
mode: "terminal_image_storage_mode",
localDir: "terminal_image_local_dir",
hostPath: "terminal_image_host_path",
ttlMs: "terminal_image_ttl_ms",
maxCount: "terminal_image_max_count",
maxBytes: "terminal_image_max_storage_bytes",
} as const;
/**
* Legacy environment variables from the original env-only configuration. They
* seed defaults only when no database value exists for the same field.
*/
export const TERMINAL_IMAGE_STORAGE_ENV = {
mode: "TERMIX_IMAGE_STORAGE_MODE",
localDir: "TERMIX_IMAGE_DIR",
hostPath: "TERMIX_IMAGE_HOST_PATH",
ttlMs: "TERMIX_IMAGE_TTL_MS",
maxCount: "TERMIX_MAX_IMAGE_COUNT",
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
} as const;
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
export const DEFAULT_IMAGE_MAX_COUNT = 100;
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
return path.resolve(
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
);
}
export function parseTerminalImageStorageMode(
value: unknown,
): TerminalImageStorageMode | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
normalized,
)
? (normalized as TerminalImageStorageMode)
: null;
}
/**
* Local write directory must be an absolute path without NUL bytes. Relative
* values are rejected rather than resolved: a relative entry silently depends
* on the process cwd, which differs between Docker, systemd and dev runs.
*/
export function parseImageLocalDir(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.isAbsolute(trimmed)) return null;
return path.resolve(trimmed);
}
/** Agent-visible path. Always POSIX-style and absolute. */
export function parseImageHostPath(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.posix.isAbsolute(trimmed)) return null;
return path.posix.normalize(trimmed);
}
/**
* Numeric fields: unparseable values are rejected (caller falls through to the
* next source); parseable but out-of-range values are clamped, matching the
* original env-only behavior.
*/
function parseClampedInt(value: unknown, min: number): number | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const parsed =
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed)) return null;
return Math.max(min, Math.trunc(parsed));
}
interface SettingSource {
get(key: string): Promise<string | null>;
}
function warnInvalid(key: string, source: string): void {
databaseLogger.warn("Ignoring invalid terminal image storage setting", {
operation: "terminal_image_storage_settings_invalid",
key,
source,
});
}
function hasUnsafePathSyntax(value: string): boolean {
return (
/[\u0000-\u001f\u007f]/.test(value) ||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
value.includes("//") ||
value.includes("\\")
);
}
/**
* Resolves the effective image storage settings.
*
* Per-field precedence: a valid persisted database value wins; a legacy
* TERMIX_IMAGE_* variable seeds the default only when no database value
* exists; otherwise the built-in default applies. Invalid values are skipped
* with a warning and resolution falls through to the next source.
*
* Mode compatibility: with no mode in the database or environment, an explicit
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
* everything else defaults to `auto`.
*/
export async function resolveTerminalImageStorageSettings(
settings: SettingSource,
env: NodeJS.ProcessEnv = process.env,
): Promise<TerminalImageStorageSettings> {
async function pick<T>(
key: string,
envName: string,
parse: (value: unknown) => T | null,
fallback: T,
): Promise<T> {
const stored = await settings.get(key);
if (stored !== null) {
const parsed = parse(stored);
if (parsed !== null) return parsed;
warnInvalid(key, "database");
}
const fromEnv = env[envName];
if (fromEnv !== undefined) {
const parsed = parse(fromEnv);
if (parsed !== null) return parsed;
warnInvalid(key, "environment");
}
return fallback;
}
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
let mode: TerminalImageStorageMode | null = null;
if (dbModeRaw !== null) {
mode = parseTerminalImageStorageMode(dbModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
}
if (mode === null) {
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
if (envModeRaw !== undefined) {
mode = parseTerminalImageStorageMode(envModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
}
}
if (mode === null) {
// Legacy deployments configured local storage purely through
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
// otherwise.
mode =
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
}
const legacyLocalDir = parseImageLocalDir(
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
);
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
pick(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
TERMINAL_IMAGE_STORAGE_ENV.localDir,
parseImageLocalDir,
defaultImageLocalDir(env),
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
parseImageHostPath,
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
(value) => parseClampedInt(value, 0),
DEFAULT_IMAGE_TTL_MS,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
(value) => parseClampedInt(value, 1),
DEFAULT_IMAGE_MAX_COUNT,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
DEFAULT_IMAGE_MAX_BYTES,
),
]);
const persistedLocalDir = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
);
const persistedHostPath = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
);
const localMappingConfigured =
((persistedLocalDir !== null &&
parseImageLocalDir(persistedLocalDir) !== null) ||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
((persistedHostPath !== null &&
parseImageHostPath(persistedHostPath) !== null) ||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
legacyLocalDir !== null);
return {
mode,
localDir,
hostPath,
ttlMs,
maxCount,
maxBytes,
localMappingConfigured,
};
}
@@ -1,675 +0,0 @@
import fs from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import {
exceedsImageStorageLimit,
isExpiredImage,
isImageFilename,
} from "./terminal-image-utils.js";
import type { TerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
/**
* Stable error codes for image storage failures. These are part of the upload
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
* not change.
*/
export type TerminalImageStorageErrorCode =
| "IMAGE_STORAGE_LIMIT_REACHED"
| "IMAGE_LOCAL_WRITE_FAILED"
| "IMAGE_LOCAL_INSPECTION_FAILED"
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
| "IMAGE_REMOTE_WRITE_FAILED";
export class TerminalImageStorageError extends Error {
constructor(
readonly code: TerminalImageStorageErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "TerminalImageStorageError";
}
}
export interface StoredTerminalImage {
id: string;
filename: string;
/** Agent-visible path; never a backend-internal path. */
shellPath: string;
storage: "local" | "remote-sftp";
}
/**
* Mode selection for one upload. Explicit modes are deterministic — they are
* returned regardless of capability and the route reports the failure; only
* `auto` falls back, and only on capability (a connected terminal session
* with SFTP), never on configuration.
*/
export function selectImageStorageMode(
settings: Pick<
TerminalImageStorageSettings,
"mode" | "localMappingConfigured"
>,
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
): "local" | "remote-sftp" | "unavailable" {
if (settings.mode === "local") return "local";
if (settings.mode === "remote-sftp") return "remote-sftp";
if (settings.localMappingConfigured && capability.localHostVisible === true) {
return "local";
}
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
}
// Capacity checks and writes are serialized so concurrent uploads cannot
// bypass the count or byte limits.
let imageStorageQueue: Promise<unknown> = Promise.resolve();
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const result = imageStorageQueue.then(operation, operation);
imageStorageQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function cleanupExpiredImages(
localDir: string,
ttlMs: number,
): Promise<void> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const filePath = path.join(localDir, entry.name);
const stat = await fs.stat(filePath);
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
await fs.unlink(filePath).catch(() => undefined);
}
}),
);
}
async function getActiveImageStorageUsage(
localDir: string,
ttlMs: number,
): Promise<{ fileCount: number; totalBytes: number }> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
const stats = await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const stat = await fs.stat(path.join(localDir, entry.name));
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
}),
);
return stats.reduce(
(usage, stat) => {
if (stat) {
usage.fileCount += 1;
usage.totalBytes += stat.size;
}
return usage;
},
{ fileCount: 0, totalBytes: 0 },
);
}
/**
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
* The returned shellPath is built from the agent-visible hostPath — the
* backend's own localDir is never exposed.
*/
export async function storeImageLocally(
image: Buffer,
settings: TerminalImageStorageSettings,
): Promise<StoredTerminalImage> {
return withImageStorageLock(async () => {
let usage: { fileCount: number; totalBytes: number };
try {
await fs.mkdir(settings.localDir, { recursive: true });
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
usage = await getActiveImageStorageUsage(
settings.localDir,
settings.ttlMs,
);
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_LOCAL_INSPECTION_FAILED",
"Unable to inspect local image storage",
error,
);
}
if (
exceedsImageStorageLimit(
usage.fileCount,
usage.totalBytes,
image.length,
settings.maxCount,
settings.maxBytes,
)
) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
const id = randomUUID();
const filename = `${id}.png`;
try {
await fs.writeFile(path.join(settings.localDir, filename), image);
} catch (error) {
await fs
.rm(path.join(settings.localDir, filename), { force: true })
.catch(() => undefined);
throw new TerminalImageStorageError(
"IMAGE_LOCAL_WRITE_FAILED",
"Failed to write image to local storage",
error,
);
}
return {
id,
filename,
shellPath: path.posix.join(settings.hostPath, filename),
storage: "local",
};
});
}
// Remote directory (on the SSH host the terminal is connected to) that
// uploaded/pasted images are written into. Always POSIX-style: this is a
// path on the remote shell, not on the Termix backend's own filesystem.
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
export interface ImageSftpClient {
mkdir(
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
callback?: (err?: Error) => void,
): void;
createWriteStream(
remotePath: string,
options?: { mode?: number },
): NodeJS.WritableStream;
stat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
lstat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
chmod?: (
dir: string,
mode: number,
callback: (error?: Error) => void,
) => void;
readdir?: (
dir: string,
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
) => void;
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
end?: () => void;
}
interface ImageSftpEntry {
filename: string;
attrs?: { mtime?: number; size?: number };
}
export interface ImageSshExecClient {
exec(
command: string,
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
): void;
}
interface ImageExecStream {
on(event: "close", listener: (code: number | null) => void): this;
on(event: "error", listener: (error: Error) => void): this;
resume(): void;
}
function quoteRemotePath(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function execBounded(
sshConn: ImageSshExecClient,
command: string,
timeoutMs = 3_000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => finish(false), timeoutMs);
sshConn.exec(command, (error, stream) => {
if (error || !stream) {
clearTimeout(timer);
finish(false);
return;
}
stream.on("close", (code) => {
clearTimeout(timer);
finish(code === 0);
});
stream.on("error", () => {
clearTimeout(timer);
finish(false);
});
stream.resume();
});
});
}
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
/** Verify a configured local mapping from the currently connected SSH session. */
export async function probeLocalImageVisibility(
sshConn: ImageSshExecClient,
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
): Promise<boolean> {
const filename = `.termix-image-probe-${randomUUID()}`;
const localProbe = path.join(settings.localDir, filename);
const remoteProbe = path.posix.join(settings.hostPath, filename);
await fs.mkdir(settings.localDir, { recursive: true });
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
try {
return await execBounded(
sshConn,
`test -f -- ${quoteRemotePath(remoteProbe)}`,
);
} finally {
await fs.unlink(localProbe).catch(() => undefined);
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
}
}
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
if (!err) {
resolve();
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(err);
return;
}
inspect(dir, (inspectError, attrs) => {
if (inspectError || !attrs) {
reject(inspectError ?? err);
return;
}
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
reject(new Error("Remote image path is not a directory"));
return;
}
if (!sftp.chmod) {
reject(
new Error("Remote image directory permissions cannot be verified"),
);
return;
}
sftp.chmod(dir, 0o700, (chmodError) => {
if (chmodError) reject(chmodError);
else resolve();
});
});
});
});
}
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
function waitForRemoteImageLock(
sftp: ImageSftpClient,
attempts = 60,
): Promise<() => Promise<void>> {
if (!sftp.rmdir) {
return Promise.reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
),
);
}
return new Promise((resolve, reject) => {
let remaining = attempts;
const tryAcquire = () => {
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
if (!error) {
resolve(
() =>
new Promise<void>((releaseResolve, releaseReject) => {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
releaseError ? releaseReject(releaseError) : releaseResolve(),
);
}),
);
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
error,
),
);
return;
}
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
const stale =
!inspectError &&
typeof attrs?.mtime === "number" &&
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
if (stale) {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
if (removeError) {
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Stale remote image storage lock cannot be removed",
removeError,
),
);
return;
}
setTimeout(tryAcquire, 50);
return;
}
tryAcquire();
});
return;
}
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock is unavailable",
error,
),
);
return;
}
setTimeout(tryAcquire, 50);
});
});
};
tryAcquire();
});
}
function sftpWriteFile(
sftp: ImageSftpClient,
remotePath: string,
data: Buffer,
timeoutMs = 10_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(remotePath, {
mode: 0o600,
}) as NodeJS.WritableStream & {
destroy?: () => void;
};
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
stream.destroy?.();
reject(new Error("SFTP image write timed out"));
}, timeoutMs);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
stream.on("error", (error: Error) => finish(error));
stream.on("close", () => finish());
stream.end(data);
});
}
async function cleanupExpiredRemoteImages(
sftp: ImageSftpClient,
ttlMs: number | undefined,
nowMs = Date.now(),
): Promise<void> {
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
resolve(error ? [] : result);
});
});
const cutoffSeconds = (nowMs - ttlMs) / 1000;
const uuidPng =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
await Promise.all(
entries
.filter(
(entry) =>
uuidPng.test(entry.filename) &&
typeof entry.attrs?.mtime === "number" &&
entry.attrs.mtime < cutoffSeconds,
)
.map((entry) =>
withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
resolve(),
);
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined),
),
);
}
const REMOTE_UUID_PNG_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
async function enforceRemoteImageLimits(
sftp: ImageSftpClient,
imageBytes: number,
maxCount: number,
maxBytes: number,
): Promise<void> {
if (!sftp.readdir) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
let entries: ImageSftpEntry[];
try {
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
error,
);
}
const images = entries.filter((entry) =>
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
);
const totalBytes = images.reduce((sum, entry) => {
if (typeof entry.attrs?.size !== "number") {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
return sum + entry.attrs.size;
}, 0);
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
}
async function storeImageViaSftpUnlocked(
sftp: ImageSftpClient,
image: Buffer,
options: {
writeTimeoutMs?: number;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
nowMs?: number;
} = {},
): Promise<StoredTerminalImage> {
const id = randomUUID();
const filename = `${id}.png`;
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
let releaseRemoteLock: (() => Promise<void>) | undefined;
let operationError: TerminalImageStorageError | undefined;
try {
await withTimeout(
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
3_000,
"SFTP directory operation timed out",
);
releaseRemoteLock = await withTimeout(
waitForRemoteImageLock(sftp),
10_000,
"SFTP lock operation timed out",
);
await withTimeout(
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
5_000,
"SFTP cleanup operation timed out",
);
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
await withTimeout(
enforceRemoteImageLimits(
sftp,
image.length,
options.maxCount ?? 100,
options.maxBytes ?? 5_368_709_120,
),
5_000,
"SFTP quota operation timed out",
);
}
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
} catch (error) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError =
error instanceof TerminalImageStorageError
? error
: new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to write image to the remote host",
error,
);
}
if (releaseRemoteLock) {
try {
await withTimeout(
releaseRemoteLock(),
3_000,
"SFTP lock release timed out",
);
} catch (releaseError) {
if (!operationError) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError = new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to release remote image storage lock",
releaseError,
);
}
}
}
if (operationError) throw operationError;
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
}
export function storeImageViaSftp(
sftp: ImageSftpClient,
image: Buffer,
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
): Promise<StoredTerminalImage> {
return withImageStorageLock(() =>
withTimeout(
storeImageViaSftpUnlocked(sftp, image, options),
20_000,
"SFTP image operation timed out",
),
);
}
@@ -1,100 +0,0 @@
// Accepted decoded input formats; uploads are normalized to PNG by the route.
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
avif: "avif",
gif: "gif",
heif: "heif",
jpeg: "jpg",
jp2: "jp2",
jxl: "jxl",
png: "png",
tiff: "tiff",
webp: "webp",
};
export function imageExtensionForFormat(
format: string | undefined,
): string | undefined {
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
}
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
export function exceedsNormalizedImageSize(
byteLength: number,
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
): boolean {
return byteLength > maxBytes;
}
export function createConcurrencyLimiter(
limit: number,
maxQueued = Number.POSITIVE_INFINITY,
): {
acquire: () => Promise<() => void>;
readonly active: number;
readonly queued: number;
} {
const max = Math.max(1, Math.floor(limit));
const queueLimit = Math.max(0, Math.floor(maxQueued));
let active = 0;
const waiters: Array<() => void> = [];
const startNext = () => {
if (active >= max || waiters.length === 0) return;
active += 1;
waiters.shift()!();
};
return {
acquire: () =>
new Promise<() => void>((resolve, reject) => {
if (active >= max && waiters.length >= queueLimit) {
reject(new Error("Concurrency admission queue is full"));
return;
}
waiters.push(() => {
let released = false;
resolve(() => {
if (released) return;
released = true;
active -= 1;
startNext();
});
});
startNext();
}),
get active() {
return active;
},
get queued() {
return waiters.length;
},
};
}
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
export function isImageFilename(filename: string): boolean {
return IMAGE_FILENAME_PATTERN.test(filename);
}
export function isExpiredImage(
modifiedAtMs: number,
nowMs: number,
ttlMs: number,
): boolean {
if (ttlMs <= 0) return false;
return nowMs - modifiedAtMs > ttlMs;
}
export function exceedsImageStorageLimit(
fileCount: number,
totalBytes: number,
incomingBytes: number,
maxFileCount: number,
maxStorageBytes: number,
): boolean {
return (
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
);
}
-740
View File
@@ -1,740 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { randomUUID } from "crypto";
import multer from "multer";
import sharp from "sharp";
import {
createConcurrencyLimiter,
exceedsNormalizedImageSize,
imageExtensionForFormat,
} from "./terminal-image-utils.js";
import { resolveTerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
import {
selectImageStorageMode,
probeLocalImageVisibility,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "./terminal-image-storage.js";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
createCurrentCommandHistoryRepository,
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../repositories/factory.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Browser image handoff for local terminal-agent workflows.
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024,
fields: 4,
fieldSize: 64 * 1024,
files: 1,
parts: 5,
headerPairs: 200,
},
});
const imageUploadMiddleware = imageUpload.single("image");
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
let imageUploadSequence = 0;
async function handleImageUploadMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let releaseAdmission: (() => void) | undefined;
try {
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
} catch {
res.status(503).json({
error: "Image upload capacity is temporarily unavailable",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
return;
}
imageUploadMiddleware(req, res, (error: unknown) => {
try {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError) {
databaseLogger.warn("Image upload multipart request rejected", {
operation: "terminal_image_upload_multipart_rejected",
code: error.code,
field: error.field,
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Image upload request rejected",
code: error.code,
field: error.field,
});
return;
}
databaseLogger.warn("Image upload multipart request malformed", {
operation: "terminal_image_upload_multipart_invalid",
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Malformed image upload request",
code: "IMAGE_MULTIPART_INVALID",
});
} finally {
releaseAdmission?.();
}
});
}
function findTerminalSession(userId: string, instanceId: string) {
return sessionManager
.getUserSessions(userId)
.find(
(session) =>
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
instanceId && session.isConnected,
);
}
router.post(
"/image-upload",
authenticateJWT,
requireDataAccess,
handleImageUploadMiddleware,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (!req.file) {
return res.status(400).json({
error: "Image required",
code: "IMAGE_FILE_MISSING",
});
}
if (!isNonEmptyString(userId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
const requestId = randomUUID();
const sequence = ++imageUploadSequence;
const source =
req.body?.source === "file" || req.body?.source === "clipboard"
? req.body.source
: undefined;
const clientUploadTimestamp =
typeof req.body?.clientUploadTimestamp === "string" &&
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
? req.body.clientUploadTimestamp
: undefined;
const serverReceivedAt = new Date().toISOString();
databaseLogger.info("Terminal image upload received", {
operation: "terminal_image_upload_received",
requestId,
sequence,
source,
clientUploadTimestamp,
serverReceivedAt,
bytes: req.file.size,
});
const storageSettings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = isNonEmptyString(instanceId)
? findTerminalSession(userId, instanceId)
: undefined;
let localHostVisible = false;
if (storageSettings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
storageSettings,
).catch(() => false);
}
const storageMode = selectImageStorageMode(storageSettings, {
remoteSftpAvailable: !!session?.sshConn,
localHostVisible,
});
if (storageMode === "unavailable") {
return res.status(503).json({
error: "Image storage is unavailable",
code: "IMAGE_STORAGE_UNAVAILABLE",
});
}
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
return res.status(503).json({
error: "Local image storage is not configured",
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
});
}
if (storageMode === "remote-sftp") {
if (!isNonEmptyString(instanceId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
if (!session || !session.sshConn) {
return res.status(409).json({
error: "Terminal is not connected",
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
}
}
let normalizedImage: Buffer;
let releaseImageProcessingSlot: (() => void) | undefined;
try {
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
} catch {
return res.status(503).json({
error: "Image upload capacity is temporarily exhausted",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
}
try {
const source = sharp(req.file.buffer, {
failOn: "error",
limitInputPixels: 40_000_000,
});
const { format } = await source.metadata();
if (!imageExtensionForFormat(format)) {
return res.status(400).json({
error: "Unsupported image format",
code: "IMAGE_FORMAT_UNSUPPORTED",
});
}
normalizedImage = await source.rotate().png().toBuffer();
if (exceedsNormalizedImageSize(normalizedImage.length)) {
return res.status(413).json({
error: "Normalized image is too large",
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
});
}
} catch (error) {
databaseLogger.warn("Image upload failed image decoding", {
operation: "terminal_image_upload_decode",
mimeType: req.file.mimetype,
bytes: req.file.size,
reason: getErrorMessage(error, "unknown"),
});
return res.status(400).json({
error: "Invalid image data",
code: "IMAGE_DECODE_FAILED",
});
} finally {
releaseImageProcessingSlot?.();
}
let remoteSftp: ImageSftpClient | undefined;
try {
const stored =
storageMode === "remote-sftp"
? await (async () => {
remoteSftp = await new Promise<ImageSftpClient>(
(resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel acquisition timed out"));
}, 3_000);
session!.sshConn!.sftp((err, sftp) => {
if (settled) {
sftp?.end?.();
return;
}
settled = true;
clearTimeout(timer);
if (err) return reject(err);
resolve(sftp);
});
},
);
return storeImageViaSftp(remoteSftp, normalizedImage, {
ttlMs: storageSettings.ttlMs,
maxCount: storageSettings.maxCount,
maxBytes: storageSettings.maxBytes,
});
})()
: await storeImageLocally(normalizedImage, storageSettings);
res.json(stored);
} catch (error) {
if (error instanceof TerminalImageStorageError) {
const status =
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
? 507
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
? 502
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
? 503
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
? 503
: 500;
databaseLogger.warn("Image upload storage write failed", {
operation:
error.code === "IMAGE_REMOTE_WRITE_FAILED"
? "terminal_image_upload_sftp_failed"
: "terminal_image_upload_local_failed",
code: error.code,
userId,
instanceId,
reason: getErrorMessage(error.cause ?? error, "unknown"),
});
return res.status(status).json({
error: error.message,
code: error.code,
});
}
databaseLogger.warn("Image upload failed to acquire remote channel", {
operation: "terminal_image_upload_sftp_failed",
code: "IMAGE_REMOTE_WRITE_FAILED",
userId,
instanceId,
reason: getErrorMessage(error, "unknown"),
});
return res.status(502).json({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
} finally {
remoteSftp?.end?.();
}
},
);
/**
* @openapi
* /terminal/command_history:
* post:
* summary: Save command to history
* description: Saves a command to the command history for a specific host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 201:
* description: Command saved successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to save command.
*/
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
const sensitivePatterns = [
/passw(or)?d/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/PASS(WORD)?=/i,
/AWS_SECRET/i,
/mysql\b.*-p/i,
/sudo\s+-S\b/,
/htpasswd/i,
/sshpass/i,
/curl\b.*-u\s/i,
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
const trimmedCommand = command.trim();
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
);
if (!globalEnabled) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const hostRecord =
await createCurrentHostResolutionRepository().findHostById(
parseInt(hostId, 10),
userId,
);
if (hostRecord?.enableCommandHistory === false) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
try {
const result = await createCurrentCommandHistoryRepository().create(
userId,
parseInt(hostId, 10),
trimmedCommand,
);
res.status(201).json(result);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid request parameters.
* 500:
* description: Failed to fetch history.
*/
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const uniqueCommands =
await createCurrentCommandHistoryRepository().listUniqueCommandsForHost(
userId,
hostIdNum,
);
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch history"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/delete:
* post:
* summary: Delete a specific command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to delete command.
*/
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostIdNum,
command.trim(),
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to delete command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* delete:
* summary: Clear command history
* description: Clears the entire command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Command history cleared successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to clear history.
*/
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await createCurrentCommandHistoryRepository().deleteByUserAndHost(
userId,
hostIdNum,
);
databaseLogger.info("Terminal history cleared", {
operation: "terminal_history_clear",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to clear history"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* get:
* summary: Get session persistence settings
* description: Returns the session timeout and persistence enabled flag.
* tags:
* - Terminal
* responses:
* 200:
* description: Session settings.
* 500:
* description: Failed to fetch settings.
*/
router.get(
"/session_settings",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
const settings = createCurrentSettingsRepository();
const timeoutValue = await settings.get(
"terminal_session_timeout_minutes",
);
const enabled = await settings.getBoolean(
"terminal_session_persistence_enabled",
true,
);
res.json({
timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30,
enabled,
});
} catch (err) {
authLogger.error("Failed to fetch session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch settings"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* post:
* summary: Update session persistence settings
* description: Saves session timeout and persistence enabled flag.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* timeoutMinutes:
* type: integer
* enabled:
* type: boolean
* responses:
* 200:
* description: Settings saved successfully.
* 400:
* description: Invalid parameters.
* 500:
* description: Failed to save settings.
*/
router.post(
"/session_settings",
authenticateJWT,
async (req: Request, res: Response) => {
const { timeoutMinutes, enabled } = req.body;
if (
timeoutMinutes !== undefined &&
(typeof timeoutMinutes !== "number" ||
timeoutMinutes < 1 ||
timeoutMinutes > 1440)
) {
return res
.status(400)
.json({ error: "timeoutMinutes must be between 1 and 1440" });
}
try {
const settings = createCurrentSettingsRepository();
if (timeoutMinutes !== undefined) {
await settings.set(
"terminal_session_timeout_minutes",
String(timeoutMinutes),
);
}
if (enabled !== undefined) {
await settings.set(
"terminal_session_persistence_enabled",
String(enabled),
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to save session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save settings"),
});
}
},
);
export default router;
@@ -1,89 +0,0 @@
// Pure, dependency-free helpers for SSH public-key parsing/classification used
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
// line is ~720 chars); this caps the value the unauthenticated resolver later
// streams, preventing a multi-MB blob being stored and amplified.
export const MAX_PUBLIC_KEY_LENGTH = 8192;
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
export const ALGO_GROUPS: Record<string, string> = {
"ssh-rsa": "RSA",
"rsa-sha2-256": "RSA",
"rsa-sha2-512": "RSA",
"ssh-dss": "DSA",
"ssh-ed25519": "ED25519",
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
"ecdsa-sha2-nistp256": "ECDSA",
"ecdsa-sha2-nistp384": "ECDSA",
"ecdsa-sha2-nistp521": "ECDSA",
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
};
export function classifyAlgo(type: string): string {
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
if (type.startsWith("ecdsa-")) return "ECDSA";
if (type.includes("ed25519")) return "ED25519";
if (type.includes("rsa")) return "RSA";
if (type.includes("dss") || type.includes("dsa")) return "DSA";
return type.toUpperCase();
}
export interface ParsedPublicKey {
type: string;
algorithm: string;
comment: string;
normalized: string; // "<type> <base64blob>"
}
/**
* Parse and validate a single OpenSSH public key line. Returns null when the
* input is not a well-formed public key.
*/
export function parsePublicKey(
raw: string | null | undefined,
): ParsedPublicKey | null {
if (typeof raw !== "string") return null;
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
const line = raw.trim().replace(/\s+/g, " ");
if (!line) return null;
const parts = line.split(" ");
if (parts.length < 2) return null;
const [type, blob, ...rest] = parts;
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
const decoded = Buffer.from(blob, "base64");
// The blob is an SSH wire-format string whose first field repeats the type.
if (decoded.length < 8) return null;
try {
const len = decoded.readUInt32BE(0);
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
const embeddedType = decoded.toString("utf8", 4, 4 + len);
if (embeddedType !== type) return null;
} catch {
return null;
}
return {
type,
algorithm: classifyAlgo(type),
comment: rest.join(" "),
normalized: `${type} ${blob}`,
};
}
/**
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
* filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
*/
export function matchesAlgoFilter(
algorithm: string,
filter: string | null,
): boolean {
if (!filter) return true;
return algorithm.toUpperCase() === filter.toUpperCase();
}
File diff suppressed because it is too large. Load diff
+128
View File
@@ -0,0 +1,128 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import { TlsValidationError } from "../../tls/certificate.js";
import {
getTlsStatus,
reloadTls,
writeTlsCertificate,
} from "../../tls/tls-service.js";
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
export function registerTlsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/tls-certificate:
* get:
* summary: Get the served TLS certificate (admin only)
* description: Returns whether HTTPS is on, the certificate core serves, and the plugin that renews it, if any.
* tags:
* - Users
* responses:
* 200:
* description: Certificate status.
* 403:
* description: Not authorized.
* 500:
* description: Failed to read the certificate.
*/
router.get("/tls-certificate", authenticateJWT, async (req, res) => {
try {
const actor = await getAdminActor((req as AuthenticatedRequest).userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
res.json(await getTlsStatus());
} catch (err) {
authLogger.error("Failed to read TLS certificate status", err);
res.status(500).json({ error: "Failed to read TLS certificate status" });
}
});
/**
* @openapi
* /users/tls-certificate:
* post:
* summary: Upload a TLS certificate and key (admin only)
* description: Validates a PEM certificate chain and private key, installs them as the served certificate and reloads HTTPS without a restart.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate installed. Carries the new status and a reload message.
* 400:
* description: Missing, unreadable, expired or mismatched certificate and key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/tls-certificate", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
const audit = (success: boolean, details: object, errorMessage?: string) =>
logAudit({
userId,
username: actor.username ?? userId,
action: "tls_certificate_upload",
resourceType: "setting",
details: JSON.stringify(details),
ipAddress,
userAgent,
success,
errorMessage,
});
try {
const { certificate, privateKey } = req.body ?? {};
const info = await writeTlsCertificate(certificate, privateKey);
const reload = await reloadTls();
await audit(true, { subject: info.subject, notAfter: info.notAfter });
res.json({
success: true,
reloadMessage: reload.message,
...(await getTlsStatus()),
});
} catch (err) {
const message = getErrorMessage(err);
await audit(false, {}, message);
if (err instanceof TlsValidationError) {
return res.status(400).json({ error: message });
}
authLogger.error("TLS certificate upload failed", err);
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
@@ -1,69 +0,0 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
normalizeTouchInputSettings,
TOUCH_INPUT_SETTING_KEY,
validateTouchInputSettingsUpdate,
} from "../../../types/touch-input-settings.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
async function readSettings() {
const raw = await createCurrentSettingsRepository().get(
TOUCH_INPUT_SETTING_KEY,
);
if (!raw) return normalizeTouchInputSettings(null);
try {
return normalizeTouchInputSettings(JSON.parse(raw));
} catch {
return normalizeTouchInputSettings(null);
}
}
export function registerTouchInputSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
router.get("/touch-input-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await readSettings());
} catch (error) {
authLogger.error("Failed to get touch input settings", error);
res.status(500).json({ error: "Failed to get touch input settings" });
}
});
router.patch("/touch-input-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const validationError = validateTouchInputSettingsUpdate(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const current = await readSettings();
const merged = { ...current, ...req.body };
const mergedValidationError = validateTouchInputSettingsUpdate(merged);
if (mergedValidationError) {
return res.status(400).json({ error: mergedValidationError });
}
const next = normalizeTouchInputSettings(merged);
await createCurrentSettingsRepository().set(
TOUCH_INPUT_SETTING_KEY,
JSON.stringify(next),
);
res.json(next);
} catch (error) {
authLogger.error("Failed to update touch input settings", error);
res.status(500).json({ error: "Failed to update touch input settings" });
}
});
}
+19 -16
View File
@@ -21,13 +21,12 @@ const authenticateJWT = authManager.createAuthMiddleware();
const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* Existing users must never be ambushed by onboarding. A user with no
* ui_preferences row who registered more than a day ago predates this feature,
* so they are handed a completed onboarding state. This is a read-time default
* rather than a migration write: nothing is persisted until the user actually
* changes something, so it stays correct on a fresh database too.
* Existing users must never be ambushed by onboarding. A user who never
* finished it and registered more than a day ago predates this feature, so
* they are handed a completed onboarding state. This also repairs rows that an
* earlier PUT wrote with the not-completed default.
*/
function withOnboardingBackfill(
export function withOnboardingBackfill(
preferences: UiPreferences,
registeredAt: string | null | undefined,
): UiPreferences {
@@ -36,7 +35,7 @@ function withOnboardingBackfill(
Number.isFinite(registeredMs) &&
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
if (isNewAccount) return preferences;
if (isNewAccount || preferences.onboarding.completedAt) return preferences;
return {
...preferences,
@@ -65,18 +64,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const existing =
await createCurrentUiPreferenceRepository().findByUserId(userId);
if (existing) {
return res.json({
preferences: sanitizeUiPreferences(JSON.parse(existing.data)),
});
const stored = existing
? sanitizeUiPreferences(JSON.parse(existing.data))
: defaultUiPreferences();
if (stored.onboarding.completedAt) {
return res.json({ preferences: stored });
}
const user = await createCurrentUserRepository().findById(userId);
return res.json({
preferences: withOnboardingBackfill(
defaultUiPreferences(),
user?.registeredAt,
),
preferences: withOnboardingBackfill(stored, user?.registeredAt),
});
} catch (e) {
databaseLogger.error("Failed to get UI preferences", e, {
@@ -117,9 +114,15 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
try {
const repository = createCurrentUiPreferenceRepository();
const existing = await repository.findByUserId(userId);
const base = existing
const stored = existing
? sanitizeUiPreferences(JSON.parse(existing.data))
: defaultUiPreferences();
const base = stored.onboarding.completedAt
? stored
: withOnboardingBackfill(
stored,
(await createCurrentUserRepository().findById(userId))?.registeredAt,
);
const body = req.body as Record<string, unknown>;
+117 -78
View File
@@ -1,3 +1,4 @@
import { isExternalAccount } from "../../auth/external-account.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
@@ -9,8 +10,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentRoleRepository,
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import {
listUserSecondFactors,
resetUserSecondFactors,
} from "../../auth/second-factor-admin.js";
import type {
UserRecord,
UserRepository,
@@ -107,11 +113,16 @@ export function registerUserAdminRoutes(
total = pageUsers.length;
}
const secondFactorUsers = requester?.isAdmin
? await createCurrentUserAuthRepository().listUserIdsWithSecondFactors()
: new Set<string>();
res.json({
users: pageUsers.map((u) => ({
userId: u.id,
username: u.username,
is_admin: u.isAdmin,
is_external: isExternalAccount(u),
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
@@ -119,7 +130,7 @@ export function registerUserAdminRoutes(
...(requester?.isAdmin
? {
data_unlocked: DataCrypto.canUserAccessData(u.id),
totp_enabled: !!u.totpEnabled,
second_factor_enabled: secondFactorUsers.has(u.id),
}
: {}),
})),
@@ -468,9 +479,6 @@ export function registerUserAdminRoutes(
identifierPath: "",
namePath: "",
scopes: "openid email profile",
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
@@ -641,7 +649,7 @@ export function registerUserAdminRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.",
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
@@ -673,92 +681,123 @@ export function registerUserAdminRoutes(
/**
* @openapi
* /users/admin/totp/disable:
* post:
* summary: Disable a user's TOTP (admin only)
* description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA.
* /users/admin/{userId}/second-factors:
* get:
* summary: List a user's second factors (admin only)
* description: Every second factor the user is enrolled in, and whether the plugin behind it is running.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: TOTP disabled for the user.
* 400:
* description: User ID is required or TOTP is not enabled.
* description: Enrolled factors.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router.post("/admin/totp/disable", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId } = req.body;
if (!isNonEmptyString(targetUserId)) {
return res.status(400).json({ error: "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(targetUserId.trim());
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!targetUser.totpEnabled) {
return res
.status(400)
.json({ error: "TOTP is not enabled for this user" });
}
await userRepository.update(targetUser.id, {
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
router.get(
"/admin/:userId/second-factors",
authenticateJWT,
async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
try {
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
} catch (saveError) {
authLogger.error("Failed to persist TOTP disable to disk", saveError, {
operation: "admin_disable_totp_save_failed",
userId: targetUser.id,
});
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(
String(req.params.userId),
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
res.json({ factors: await listUserSecondFactors(targetUser.id) });
} catch (err) {
authLogger.error("Failed to list second factors", err);
res.status(500).json({ error: "Failed to list second factors" });
}
},
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_disable_totp",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
/**
* @openapi
* /users/admin/{userId}/second-factors:
* delete:
* summary: Reset a user's second factors (admin only)
* description: Removes every second factor the user enrolled in, including ones whose plugin is disabled or missing, and forgets their trusted devices. The user can sign in with their first factor and enrol again.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Second factors reset.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
*/
router.delete(
"/admin/:userId/second-factors",
authenticateJWT,
async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(
String(req.params.userId),
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
res.json({ message: "TOTP disabled" });
} catch (err) {
authLogger.error("Failed to disable TOTP for user", err);
res.status(500).json({ error: "Failed to disable TOTP" });
}
});
const removed = await resetUserSecondFactors(targetUser.id);
try {
await DatabaseSaveTrigger.forceSave("admin_reset_second_factors");
} catch (saveError) {
authLogger.error("Failed to persist second factor reset", saveError, {
operation: "admin_reset_second_factors_save_failed",
userId: targetUser.id,
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_reset_second_factors",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
details: JSON.stringify({ removed }),
ipAddress,
userAgent,
success: true,
});
res.json({ message: "Second factors reset", removed });
} catch (err) {
authLogger.error("Failed to reset second factors", err);
res.status(500).json({ error: "Failed to reset second factors" });
}
},
);
/**
* @openapi
@@ -0,0 +1,285 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentUserRepository,
createCurrentUserAuthRepository,
} from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import { isExternalAccount } from "../../auth/external-account.js";
type UserExternalAccountRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
/**
* Merging an account that only signs in externally (SSO, LDAP or any login
* plugin) into a password account, and taking the external sign-in away
* again. The identities themselves live in user_external_identities.
*/
export function registerUserExternalAccountRoutes(
router: Router,
{ authenticateJWT, authManager }: UserExternalAccountRoutesDeps,
): void {
/**
* @openapi
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* externalUserId:
* type: string
* targetUsername:
* type: string
* responses:
* 200:
* description: Accounts linked successfully.
* 400:
* description: Invalid request or incompatible accounts.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to link accounts.
*/
const link = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
const targetUsername = req.body?.targetUsername;
if (
!isNonEmptyString(externalUserId) ||
!isNonEmptyString(targetUsername)
) {
return res.status(400).json({
error: "External user ID and target username are required",
});
}
try {
const userRepository = createCurrentUserRepository();
const identities = createCurrentUserAuthRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const externalUser = await userRepository.findById(externalUserId);
if (!externalUser) {
return res.status(404).json({ error: "External user not found" });
}
if (!isExternalAccount(externalUser)) {
return res
.status(400)
.json({ error: "Source user does not sign in externally" });
}
const targetUser = await userRepository.findByUsername(targetUsername);
if (!targetUser) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
}
authLogger.info("Linking an external account to a password account", {
operation: "link_external_to_password",
externalUserId,
targetUserId: targetUser.id,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: externalUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
await authManager.revokeAllUserSessions(externalUserId);
authManager.logoutUser(externalUserId);
await deleteUserAndRelatedData(externalUserId);
try {
await DatabaseSaveTrigger.forceSave("link_external_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
saveError,
{
operation: "link_external_save_failed",
externalUserId,
targetUserId: targetUser.id,
},
);
}
authLogger.success(
`External account ${externalUser.username} linked to password account ${targetUser.username}`,
{
operation: "link_external_to_password_success",
externalUserId,
targetUserId: targetUser.id,
adminUserId,
},
);
res.json({
success: true,
message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`,
});
} catch (err) {
authLogger.error("Failed to link an external account", err, {
operation: "link_external_to_password_failed",
externalUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to link accounts",
details: getErrorMessage(err),
});
}
};
/**
* @openapi
* /users/unlink-external-from-password:
* post:
* summary: Remove external sign-in from a password account
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: External sign-in removed.
* 400:
* description: Invalid request, or the user has no external sign-in or no password.
* 403:
* description: Admin privileges required.
* 404:
* description: User not found.
* 500:
* description: Failed to unlink.
*/
const unlink = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { userId } = req.body ?? {};
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
authLogger.warn("Non-admin attempted to remove an external sign-in", {
operation: "unlink_external_unauthorized",
adminUserId,
targetUserId: userId,
});
return res.status(403).json({ error: "Admin privileges required" });
}
const targetUser = await userRepository.findById(userId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!isExternalAccount(targetUser)) {
return res
.status(400)
.json({ error: "User does not have an external sign-in" });
}
if (!targetUser.passwordHash) {
return res.status(400).json({
error:
"Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.",
});
}
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
try {
await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to save database after removing an external sign-in",
saveError,
{
operation: "unlink_external_save_failed",
targetUserId: targetUser.id,
},
);
}
authLogger.success("External sign-in removed from password account", {
operation: "unlink_external_from_password_success",
targetUserId: targetUser.id,
adminUserId,
});
res.json({
success: true,
message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`,
});
} catch (err) {
authLogger.error("Failed to remove an external sign-in", err, {
operation: "unlink_external_from_password_failed",
targetUserId: userId,
adminUserId,
});
res.status(500).json({
error: "Failed to unlink",
details: getErrorMessage(err),
});
}
};
router.post("/link-external-to-password", authenticateJWT, link);
router.post("/unlink-external-from-password", authenticateJWT, unlink);
// 2.8 paths, kept until 3.0.0.
router.post("/link-oidc-to-password", authenticateJWT, link);
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
}
@@ -1,334 +0,0 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
MIN_IMAGE_MAX_BYTES,
TERMINAL_IMAGE_STORAGE_KEYS,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
type TerminalImageStorageSettings,
} from "./terminal-image-storage-settings.js";
import {
probeLocalImageVisibility,
selectImageStorageMode,
} from "./terminal-image-storage.js";
/**
* Admin-only terminal image storage settings.
*
* The public shape deliberately omits `localDir`: it is a backend-internal
* path and only the agent-visible `hostPath` may leave the server. No
* credentials or connection details are ever returned here.
*/
interface PublicImageStorageSettings {
mode: TerminalImageStorageSettings["mode"];
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
function toPublicSettings(
settings: TerminalImageStorageSettings,
): PublicImageStorageSettings {
return {
mode: settings.mode,
hostPath: settings.hostPath,
ttlMs: settings.ttlMs,
maxCount: settings.maxCount,
maxBytes: settings.maxBytes,
localMappingConfigured: settings.localMappingConfigured,
};
}
const PATCHABLE_FIELDS = [
"mode",
"localDir",
"hostPath",
"ttlMs",
"maxCount",
"maxBytes",
] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function invalidField(res: Response, field: string): void {
// Safe by construction: the field name is fixed, the rejected value and
// any backend path are never echoed back.
res.status(400).json({
error: `Invalid value for ${field}`,
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field,
});
}
function parseIntegerField(value: unknown, min: number): number | null {
if (typeof value !== "number" || !Number.isInteger(value)) return null;
if (value < min) return null;
return value;
}
/**
* Validates a PATCH body and returns the settings-table writes it implies, or
* null after the response has already been failed with a 400.
*/
function buildImageStorageWrites(
body: unknown,
res: Response,
): Array<{ key: string; value: string }> | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({
error: "Invalid request body",
code: "IMAGE_STORAGE_SETTINGS_INVALID",
});
return null;
}
for (const field of Object.keys(body)) {
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
res.status(400).json({
error: `Unknown setting field: ${field}`,
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
field,
});
return null;
}
}
const input = body as Partial<Record<PatchableField, unknown>>;
const writes: Array<{ key: string; value: string }> = [];
if (input.mode !== undefined) {
const mode = parseTerminalImageStorageMode(input.mode);
if (mode === null) {
invalidField(res, "mode");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.mode, value: mode });
}
if (input.localDir !== undefined) {
const localDir = parseImageLocalDir(input.localDir);
if (localDir === null) {
invalidField(res, "localDir");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.localDir, value: localDir });
}
if (input.hostPath !== undefined) {
const hostPath = parseImageHostPath(input.hostPath);
if (hostPath === null) {
invalidField(res, "hostPath");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
value: hostPath,
});
}
if (input.ttlMs !== undefined) {
const ttlMs = parseIntegerField(input.ttlMs, 0);
if (ttlMs === null) {
invalidField(res, "ttlMs");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
value: String(ttlMs),
});
}
if (input.maxCount !== undefined) {
const maxCount = parseIntegerField(input.maxCount, 1);
if (maxCount === null) {
invalidField(res, "maxCount");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
value: String(maxCount),
});
}
if (input.maxBytes !== undefined) {
const maxBytes = parseIntegerField(input.maxBytes, MIN_IMAGE_MAX_BYTES);
if (maxBytes === null) {
invalidField(res, "maxBytes");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
value: String(maxBytes),
});
}
return writes;
}
export function registerUserImageStorageRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /users/terminal-image-storage-settings:
* get:
* summary: Get terminal image storage settings (admin only)
* description: Returns the effective terminal image storage settings. The backend-internal localDir is never exposed; only the agent-visible hostPath is returned.
* tags:
* - Users
* responses:
* 200:
* description: Effective image storage settings.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to load settings.
*/
router.get(
"/terminal-image-storage-settings",
requireAdmin,
async (_req: Request, res: Response) => {
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
res.json(toPublicSettings(settings));
} catch (err) {
databaseLogger.error("Failed to load image storage settings", err);
res
.status(500)
.json({ error: "Failed to load image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings:
* patch:
* summary: Update terminal image storage settings (admin only)
* description: Persists a partial update. Accepts only mode (auto, local, remote-sftp), localDir, hostPath, ttlMs, maxCount and maxBytes; invalid values are rejected with a 400.
* tags:
* - Users
* responses:
* 200:
* description: Updated effective settings.
* 400:
* description: Invalid or unknown setting field.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to save settings.
*/
router.patch(
"/terminal-image-storage-settings",
requireAdmin,
async (req: Request, res: Response) => {
const writes = buildImageStorageWrites(req.body, res);
if (writes === null) return;
try {
const settings = createCurrentSettingsRepository();
if (typeof settings.setMany !== "function") {
throw new Error("Atomic settings persistence is unavailable");
}
await settings.setMany(writes);
const resolved = await resolveTerminalImageStorageSettings(settings);
res.json(toPublicSettings(resolved));
} catch (err) {
databaseLogger.error("Failed to save image storage settings", err);
res
.status(500)
.json({ error: "Failed to save image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings/test:
* post:
* summary: Test image storage visibility (admin only)
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
* tags:
* - Users
* responses:
* 200:
* description: Visibility test result.
* 400:
* description: Missing terminal session instanceId.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to run the test.
*/
router.post(
"/terminal-image-storage-settings/test",
requireAdmin,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = sessionManager
.getUserSessions(userId)
.find(
(candidate) =>
(candidate.attachedTabInstanceId ?? candidate.tabInstanceId) ===
instanceId && candidate.isConnected,
);
const remoteSftpAvailable = !!session?.sshConn;
let localHostVisible: boolean | null = null;
if (settings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
settings,
).catch(() => false);
}
const selectedMode = selectImageStorageMode(settings, {
remoteSftpAvailable,
...(localHostVisible !== null ? { localHostVisible } : {}),
});
res.json({
mode: settings.mode,
connected: !!session,
remoteSftpAvailable,
localHostVisible,
selectedMode,
localMappingConfigured: settings.localMappingConfigured,
});
} catch (err) {
databaseLogger.error("Failed to test image storage visibility", err);
res
.status(500)
.json({ error: "Failed to test image storage visibility" });
}
},
);
}
@@ -1,306 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
type UserOidcAccountRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserOidcAccountRoutes(
router: Router,
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
): void {
/**
* @openapi
* /users/link-oidc-to-password:
* post:
* summary: Link OIDC user to password account
* description: Merges an OIDC-only account into a password-based account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oidcUserId:
* type: string
* targetUsername:
* type: string
* responses:
* 200:
* description: Accounts linked successfully.
* 400:
* description: Invalid request or incompatible accounts.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to link accounts.
*/
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { oidcUserId, targetUsername } = req.body;
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
return res.status(400).json({
error: "OIDC user ID and target username are required",
});
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const oidcUser = await userRepository.findById(oidcUserId);
if (!oidcUser) {
return res.status(404).json({ error: "OIDC user not found" });
}
if (!oidcUser.isOidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
const targetUser = await userRepository.findByUsername(targetUsername);
if (!targetUser) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
if (targetUser.isOidc || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if (targetUser.clientId && targetUser.oidcIdentifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
});
}
authLogger.info("Linking OIDC user to password account", {
operation: "link_oidc_to_password",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: oidcUser.oidcIdentifier,
clientId: oidcUser.clientId,
clientSecret: oidcUser.clientSecret,
issuerUrl: oidcUser.issuerUrl,
authorizationUrl: oidcUser.authorizationUrl,
tokenUrl: oidcUser.tokenUrl,
identifierPath: oidcUser.identifierPath,
namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
});
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
await deleteUserAndRelatedData(oidcUserId);
try {
await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
saveError,
{
operation: "link_oidc_save_failed",
oidcUserId,
targetUserId: targetUser.id,
},
);
}
authLogger.success(
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
{
operation: "link_oidc_to_password_success",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
},
);
res.json({
success: true,
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
});
} catch (err) {
authLogger.error("Failed to link OIDC user to password account", err, {
operation: "link_oidc_to_password_failed",
oidcUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to link accounts",
details: getErrorMessage(err),
});
}
});
/**
* @openapi
* /users/unlink-oidc-from-password:
* post:
* summary: Unlink OIDC from password account
* description: Removes OIDC authentication from a dual-auth account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: OIDC unlinked successfully.
* 400:
* description: Invalid request or user doesn't have OIDC.
* 403:
* description: Admin privileges required.
* 404:
* description: User not found.
* 500:
* description: Failed to unlink OIDC.
*/
router.post(
"/unlink-oidc-from-password",
authenticateJWT,
async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { userId } = req.body;
if (!userId) {
return res.status(400).json({
error: "User ID is required",
});
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
operation: "unlink_oidc_unauthorized",
adminUserId,
targetUserId: userId,
});
return res.status(403).json({
error: "Admin privileges required",
});
}
const targetUser = await userRepository.findById(userId);
if (!targetUser) {
return res.status(404).json({
error: "User not found",
});
}
if (!targetUser.isOidc) {
return res.status(400).json({
error: "User does not have OIDC authentication enabled",
});
}
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
return res.status(400).json({
error:
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
});
}
authLogger.info("Unlinking OIDC from password account", {
operation: "unlink_oidc_from_password_start",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to save database after unlinking OIDC",
saveError,
{
operation: "unlink_oidc_save_failed",
targetUserId: targetUser.id,
},
);
}
authLogger.success("OIDC unlinked from password account successfully", {
operation: "unlink_oidc_from_password_success",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
res.json({
success: true,
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
});
} catch (err) {
authLogger.error("Failed to unlink OIDC from password account", err, {
operation: "unlink_oidc_from_password_failed",
targetUserId: userId,
adminUserId,
});
res.status(500).json({
error: "Failed to unlink OIDC",
details: getErrorMessage(err),
});
}
},
);
}
@@ -1,648 +0,0 @@
import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
import { Agent } from "undici";
import {
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
} from "../repositories/factory.js";
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
/**
* Raised when a token cannot be verified because it is not a compact JWS,
* as opposed to a signature or claim check that actually failed.
*/
export class OIDCTokenFormatError extends Error {
constructor(message: string) {
super(message);
this.name = "OIDCTokenFormatError";
}
}
function normalizeIssuer(url: string): string {
return url
.trim()
.replace(/\/+$/, "")
.replace(/\/\.well-known\/openid-configuration$/, "");
}
export type OIDCConfig = {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
identifier_path: string;
name_path: string;
scopes: string;
allowed_users: string;
admin_group: string;
group_claim?: string;
role_map?: string;
ca_cert?: string;
};
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
if (!caCert || !caCert.trim()) return {};
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
}
/**
* Renders why a fetch failed in a form an administrator can act on.
*
* undici reports every transport failure as the same "fetch failed" message
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
* the outer message says nothing at all.
*/
export function describeFetchFailure(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
const code = (cause as { code?: unknown }).code;
return code
? `${error.message}: ${cause.message} (${code})`
: `${error.message}: ${cause.message}`;
}
return cause ? `${error.message}: ${String(cause)}` : error.message;
}
export function getOIDCConfigFromEnv(): OIDCConfig | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
const issuer_url = process.env.OIDC_ISSUER_URL;
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
const token_url = process.env.OIDC_TOKEN_URL;
if (
!client_id ||
!client_secret ||
!issuer_url ||
!authorization_url ||
!token_url
) {
return null;
}
return {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: process.env.OIDC_USERINFO_URL || "",
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
name_path: process.env.OIDC_NAME_PATH || "name",
scopes: process.env.OIDC_SCOPES || "openid email profile",
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
admin_group: process.env.OIDC_ADMIN_GROUP || "",
group_claim: process.env.OIDC_GROUP_CLAIM || "",
role_map: process.env.OIDC_ROLE_MAP || "",
};
}
export function isOIDCEnvOverrideEnabled(): boolean {
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
}
/**
* Normalizes a group name for comparison. Providers are inconsistent about
* whether they emit bare names (`devops-interns`) or full paths
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
* are stripped and case is ignored.
*/
function normalizeGroupName(group: string): string {
return group.trim().replace(/^\/+/, "").toLowerCase();
}
/**
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
*
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
* normalized via {@link normalizeGroupName}; role names are passed through
* verbatim because they must match `roles.name` exactly.
*
* Malformed entries are skipped rather than throwing — a typo in one pair must
* not lock every user out of login.
*/
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
const map = new Map<string, string>();
if (!raw || !raw.trim()) return map;
for (const entry of raw.split(/[\n,]/)) {
const trimmed = entry.trim();
if (!trimmed) continue;
// rsplit on the last ":" so group names containing a colon still work.
const separator = trimmed.lastIndexOf(":");
if (separator <= 0 || separator === trimmed.length - 1) continue;
const group = normalizeGroupName(trimmed.slice(0, separator));
const roleName = trimmed.slice(separator + 1).trim();
if (!group || !roleName) continue;
map.set(group, roleName);
}
return map;
}
/**
* Resolves which mapped roles a user should hold, given their provider groups.
*
* Returns both the `desired` roles (mapped groups the user is actually in) and
* the full set of `managed` roles (every role named in the map). Callers must
* only ever add/remove roles within `managed` — roles assigned by hand in
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
* deliberately left alone.
*/
export function resolveOidcMappedRoles(
groups: string[],
roleMap: Map<string, string>,
): { desired: Set<string>; managed: Set<string> } {
const managed = new Set(roleMap.values());
const desired = new Set<string>();
for (const group of groups) {
const roleName = roleMap.get(normalizeGroupName(group));
if (roleName) desired.add(roleName);
}
return { desired, managed };
}
/**
* Extracts the list of group/role names from an OIDC userInfo payload.
*
* When `groupClaim` is set, that claim is read first (useful for providers like
* Zitadel that nest roles under a custom path such as
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
* and `group` claims are tried. Values may be an array, a comma-separated
* string, or an object whose keys are the group names.
*/
export function extractOidcGroups(
userInfo: Record<string, unknown>,
groupClaim?: string,
): string[] {
let raw: unknown;
if (groupClaim && groupClaim.trim()) {
raw = userInfo[groupClaim.trim()];
}
if (raw === undefined || raw === null) {
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
}
if (Array.isArray(raw)) {
return raw.map(String);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (raw && typeof raw === "object") {
return Object.keys(raw as Record<string, unknown>);
}
return [];
}
/**
* OIDC providers may return group claims in the ID token, userinfo response,
* or both. Keep every verified source authoritative instead of letting a
* sparse userinfo payload overwrite claims from the ID token.
*/
export function extractOidcGroupsFromSources(
sources: Record<string, unknown>[],
groupClaim?: string,
): string[] {
return [
...new Set(
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
),
];
}
export function isOIDCUserAllowed(
allowedUsers: string,
identifier: string,
email?: string,
): boolean {
if (!allowedUsers || !allowedUsers.trim()) return true;
const patterns = allowedUsers
.split(/[\n,]/)
.map((p) => p.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
const values = [
identifier,
...(email && email !== identifier ? [email] : []),
];
for (const pattern of patterns) {
if (pattern === "*") return true;
if (pattern.includes("*")) {
const escaped = pattern
.toLowerCase()
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`);
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
continue;
}
for (const value of values) {
if (!value) continue;
if (pattern.toLowerCase().startsWith("@")) {
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
} else {
if (value.toLowerCase() === pattern.toLowerCase()) return true;
}
}
}
return false;
}
export async function verifyOIDCToken(
idToken: string,
issuerUrl: string,
clientId: string,
caCert?: string,
): Promise<Record<string, unknown>> {
const segments = idToken.split(".");
if (segments.length !== 3) {
throw new OIDCTokenFormatError(
segments.length === 5
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
);
}
const fetchOptions = buildFetchOptions(caCert);
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
const possibleIssuers = [
normalizedIssuerUrl,
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
];
const jwksUrls = [
`${normalizedIssuerUrl}/.well-known/jwks.json`,
`${normalizedIssuerUrl}/jwks/`,
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
];
// Every attempt records why it failed. Without this the only thing an
// administrator ever sees is "Failed to fetch JWKS from any URL", which
// does not distinguish an issuer URL typo from a proxy, a private CA, or
// a provider outage.
const attempts: string[] = [];
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
try {
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
if (!discoveryResponse.ok) {
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
} else {
const discovery = (await discoveryResponse.json()) as Record<
string,
unknown
>;
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
jwksUrls.unshift(discovery.jwks_uri);
} else {
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
}
}
} catch (discoveryError) {
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
}
let jwks: Record<string, unknown> | null = null;
for (const url of jwksUrls) {
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
attempts.push(`${url}: HTTP ${response.status}`);
continue;
}
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && Array.isArray(jwksData.keys)) {
jwks = jwksData;
break;
}
attempts.push(`${url}: response contains no "keys" array`);
} catch (error) {
attempts.push(`${url}: ${describeFetchFailure(error)}`);
}
}
if (!jwks) {
throw new Error(
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
);
}
if (!jwks.keys || !Array.isArray(jwks.keys)) {
throw new Error(
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
);
}
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
const header = decodeProtectedHeader(idToken);
const keyId = header.kid;
const publicKey = jwks.keys.find(
(key: Record<string, unknown>) => key.kid === keyId,
);
if (!publicKey) {
throw new Error(
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
);
}
const algorithm =
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
const key = await importJWK(publicKey, algorithm);
const { payload } = await jwtVerify(idToken, key, {
issuer: possibleIssuers,
audience: clientId,
});
return payload;
}
const GOOGLE_DEFAULTS = {
issuer_url: "https://accounts.google.com",
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
};
const GITHUB_DEFAULTS = {
issuer_url: "https://token.actions.githubusercontent.com",
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
userinfo_url: "https://api.github.com/user",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
};
function applyProviderDefaults(
config: OIDCConfig,
providerType: string,
): OIDCConfig {
const defaults =
providerType === "google"
? GOOGLE_DEFAULTS
: providerType === "github"
? GITHUB_DEFAULTS
: null;
if (!defaults) return config;
return {
...config,
issuer_url: config.issuer_url || defaults.issuer_url,
authorization_url: config.authorization_url || defaults.authorization_url,
token_url: config.token_url || defaults.token_url,
userinfo_url: config.userinfo_url || defaults.userinfo_url,
identifier_path: config.identifier_path || defaults.identifier_path,
name_path: config.name_path || defaults.name_path,
scopes: config.scopes || defaults.scopes,
};
}
/**
* Reads the provider secrets. System-key encrypted values are decrypted;
* values still carrying a legacy base64 prefix are decoded so login keeps
* working until the provider is next saved.
*/
async function decryptConfigSecret(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return decryptSsoConfigSecrets(config);
}
export async function loadProviderConfig(
providerId: number | null | undefined,
adminUserId?: string,
): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
if (providerId != null) {
try {
const row =
await createCurrentSsoProviderRepository().findById(providerId);
if (row) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
parsed = {};
}
if (adminUserId) {
try {
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
if (adminDataKey) {
parsed = DataCrypto.decryptRecord(
"settings",
parsed,
adminUserId,
adminDataKey,
);
}
} catch {
parsed = await decryptConfigSecret(parsed);
}
} else {
parsed = await decryptConfigSecret(parsed);
}
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
return {
config,
providerType,
providerDbId: row.id,
};
}
} catch (err) {
authLogger.error("Failed to load SSO provider config by id", err, {
providerId,
});
}
}
// Fallback: env vars
if (envConfig) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
// Fallback: first enabled OIDC-type provider in ssoProviders table
try {
const oidcRow =
await createCurrentSsoProviderRepository().findFirstEnabledOidcLike();
if (oidcRow) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(oidcRow.config);
} catch {
parsed = {};
}
parsed = await decryptConfigSecret(parsed);
const oidcProviderType = oidcRow.type as SSOProviderType;
return {
config: applyProviderDefaults(
parsed as unknown as OIDCConfig,
oidcProviderType,
),
providerType: oidcProviderType,
providerDbId: oidcRow.id,
};
}
} catch {
// fall through to legacy
}
// Fallback: legacy settings blob
try {
const legacyValue =
await createCurrentSettingsRepository().get("oidc_config");
if (legacyValue) {
let config = JSON.parse(legacyValue) as Record<string, unknown>;
config = await decryptConfigSecret(config);
return {
config: config as unknown as OIDCConfig,
providerType: "oidc",
providerDbId: null,
};
}
} catch {
// no legacy config
}
return null;
}
export async function resolveProviderByIssuer(issuer: string): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const target = normalizeIssuer(issuer);
const envConfig = getOIDCConfigFromEnv();
if (
envConfig?.issuer_url &&
isOIDCEnvOverrideEnabled() &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
try {
const rows = await createCurrentSsoProviderRepository().listEnabled();
for (const row of rows) {
if (!["oidc", "github", "google"].includes(row.type)) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
continue;
}
parsed = await decryptConfigSecret(parsed);
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
return { config, providerType, providerDbId: row.id };
}
}
} catch (err) {
authLogger.error("Failed to resolve SSO provider by issuer", err, {
issuer,
});
}
if (
envConfig?.issuer_url &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
return null;
}
export type LogoutTokenClaims = {
sub: string | null;
sid: string | null;
jti: string;
};
export function validateLogoutTokenClaims(
payload: Record<string, unknown>,
): LogoutTokenClaims {
if ("nonce" in payload) {
throw new Error("logout_token must not contain a nonce claim");
}
const event = (payload.events as Record<string, unknown> | undefined)?.[
BACKCHANNEL_LOGOUT_EVENT
];
if (!event || typeof event !== "object" || Array.isArray(event)) {
throw new Error("logout_token missing back-channel logout event");
}
if (!Number.isInteger(payload.iat)) {
throw new Error("logout_token missing iat claim");
}
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
if (!jti) {
throw new Error("logout_token missing jti claim");
}
const sub = typeof payload.sub === "string" ? payload.sub : null;
const sid = typeof payload.sid === "string" ? payload.sid : null;
if (!sub && !sid) {
throw new Error("logout_token must contain sub and/or sid");
}
return { sub, sid, jti };
}
export async function validateLogoutToken(
logoutToken: string,
config: OIDCConfig,
): Promise<LogoutTokenClaims> {
const payload = await verifyOIDCToken(
logoutToken,
config.issuer_url,
config.client_id,
config.ca_cert,
);
return validateLogoutTokenClaims(payload);
}
@@ -7,15 +7,13 @@ import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
@@ -70,12 +68,12 @@ export async function resetUserPassword(
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (anything keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
@@ -84,11 +82,11 @@ export async function resetUserPassword(
await deleteLegacyWraps(userId);
await authManager.logoutUser(userId);
await userRepository.update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
// Same as 2.8, where the wipe took the TOTP secret with it: the user sets
// up their second factors again after this.
const { resetUserSecondFactors } =
await import("../../auth/second-factor-admin.js");
await resetUserSecondFactors(userId);
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted because the old key was unrecoverable.`,
@@ -418,7 +416,7 @@ export function registerUserPasswordResetRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.",
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
+10 -156
View File
@@ -20,47 +20,26 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
storageMode: row?.storageMode ?? "cloud",
commandAutocomplete: row?.commandAutocomplete ?? null,
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
showHostTags: row?.showHostTags ?? null,
hostTrayOnClick: row?.hostTrayOnClick ?? null,
pinAppRail: row?.pinAppRail ?? null,
expandAppRailOnHover: row?.expandAppRailOnHover ?? null,
showPinAppRailButton: row?.showPinAppRailButton ?? null,
foldersCollapsed: row?.foldersCollapsed ?? null,
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
aiAssistantEnabled: row?.aiAssistantEnabled ?? null,
aiReadOnlyCommands: row?.aiReadOnlyCommands ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
rdpDefaults: row?.rdpDefaults ?? null,
terminalMacros: row?.terminalMacros ?? null,
});
const connectionDefaultFields = ["terminalDefaults", "rdpDefaults"] as const;
export function validateDefaultsJson(value: string): boolean {
if (value.length > 32_768) return false;
try {
const parsed = JSON.parse(value);
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/**
* @openapi
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences.
* tags:
* - User Preferences
* responses:
@@ -88,9 +67,6 @@ export function validateDefaultsJson(value: string): boolean {
* storageMode:
* type: string
* nullable: true
* commandAutocomplete:
* type: boolean
* nullable: true
* commandPaletteEnabled:
* type: boolean
* nullable: true
@@ -109,12 +85,6 @@ export function validateDefaultsJson(value: string): boolean {
* showPinAppRailButton:
* type: boolean
* nullable: true
* foldersCollapsed:
* type: boolean
* nullable: true
* confirmSnippetExecution:
* type: boolean
* nullable: true
* disableUpdateCheck:
* type: boolean
* nullable: true
@@ -130,14 +100,10 @@ export function validateDefaultsJson(value: string): boolean {
* statusColorScheme:
* type: string
* nullable: true
* customThemes:
* type: string
* nullable: true
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -160,7 +126,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. Values that moved into a feature's own user settings are ignored too.
* tags:
* - User Preferences
* requestBody:
@@ -182,8 +148,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: string
* storageMode:
* type: string
* commandAutocomplete:
* type: boolean
* commandPaletteEnabled:
* type: boolean
* pinAppRail:
@@ -192,20 +156,15 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* showPinAppRailButton:
* type: boolean
* confirmSnippetExecution:
* type: boolean
* disableUpdateCheck:
* type: boolean
* confirmTabClose:
* type: boolean
* hiddenRailTabs:
* type: string
* customThemes:
* type: string
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -219,22 +178,14 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
accentColor,
language,
storageMode,
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
aiAssistantEnabled,
aiReadOnlyCommands,
customThemes,
customKeybindings,
terminalDefaults,
rdpDefaults,
terminalMacros,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -242,28 +193,21 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
rdpDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
// foldersCollapsed are no longer writable here -- they moved to
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
// in the table (read once as a migration seed by that route) but this
// endpoint silently ignores them if a stale client still sends them.
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. The same goes for the preferences that
// moved into a plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
@@ -285,58 +229,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
language,
storageMode,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
rdpDefaults,
terminalMacros,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
const connectionDefaults = {
terminalDefaults,
rdpDefaults,
};
for (const key of connectionDefaultFields) {
const value = connectionDefaults[key];
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
return res.status(400).json({
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
});
}
}
if (customThemes !== undefined && customThemes !== null) {
let parsedThemes: unknown;
try {
parsedThemes = JSON.parse(customThemes);
} catch {
return res
.status(400)
.json({ error: "customThemes must be a JSON-encoded array" });
}
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
return res.status(400).json({
error: "customThemes must be a JSON array of at most 100 themes",
});
}
const isValidTheme = (entry: unknown): boolean =>
!!entry &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string" &&
!!(entry as { colors?: unknown }).colors &&
typeof (entry as { colors?: unknown }).colors === "object";
if (!parsedThemes.every(isValidTheme)) {
return res.status(400).json({
error: "Each custom theme must have an id, name, and colors object",
});
}
}
if (customKeybindings !== undefined && customKeybindings !== null) {
let parsedKeybindings: unknown;
try {
@@ -359,45 +258,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
if (terminalMacros !== undefined && terminalMacros !== null) {
let parsedMacros: unknown;
try {
parsedMacros = JSON.parse(terminalMacros);
} catch {
return res
.status(400)
.json({ error: "terminalMacros must be a JSON-encoded array" });
}
if (
terminalMacros.length > 512 * 1024 ||
!Array.isArray(parsedMacros) ||
parsedMacros.length > 100 ||
!parsedMacros.every(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
) {
return res.status(400).json({
error: "terminalMacros must contain at most 100 valid macros",
});
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
aiAssistantEnabled,
aiReadOnlyCommands,
};
for (const [key, value] of Object.entries(boolFields)) {
if (value !== undefined && value !== null && typeof value !== "boolean") {
@@ -411,12 +278,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (language !== undefined) updates.language = language;
if (storageMode !== undefined) updates.storageMode = storageMode;
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
if (aiAssistantEnabled !== undefined)
updates.aiAssistantEnabled = aiAssistantEnabled;
if (aiReadOnlyCommands !== undefined)
updates.aiReadOnlyCommands = aiReadOnlyCommands;
if (commandAutocomplete !== undefined)
updates.commandAutocomplete = commandAutocomplete;
if (commandPaletteEnabled !== undefined)
updates.commandPaletteEnabled = commandPaletteEnabled;
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
@@ -424,18 +285,11 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.expandAppRailOnHover = expandAppRailOnHover;
if (showPinAppRailButton !== undefined)
updates.showPinAppRailButton = showPinAppRailButton;
if (confirmSnippetExecution !== undefined)
updates.confirmSnippetExecution = confirmSnippetExecution;
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customThemes !== undefined) updates.customThemes = customThemes;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (rdpDefaults !== undefined) updates.rdpDefaults = rdpDefaults;
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
File diff suppressed because it is too large. Load diff
@@ -1,742 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import {
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
type NativeAppRequestChecker = (req: Request) => boolean;
interface UserTotpRoutesDeps {
authenticateJWT: RequestHandler;
authManager: AuthManager;
isNativeAppRequest: NativeAppRequestChecker;
}
export async function verifyTotpReauth(
userRecord: UserRecord,
credential: string,
userDataKey?: Buffer | null,
): Promise<boolean> {
if (userRecord.totpSecret) {
const totpSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userRecord.id,
"totpSecret",
)
: userRecord.totpSecret;
if (totpSecret) {
const totpMatch = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: credential,
window: 2,
});
if (totpMatch) {
return true;
}
}
}
const rawBackupCodes =
userDataKey && userRecord.totpBackupCodes
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpBackupCodes,
userDataKey,
userRecord.id,
"totpBackupCodes",
)
: userRecord.totpBackupCodes;
let backupCodes: unknown = [];
try {
backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : [];
} catch {
backupCodes = [];
}
if (Array.isArray(backupCodes)) {
const backupIndex = backupCodes.indexOf(credential);
if (backupIndex !== -1) {
backupCodes.splice(backupIndex, 1);
const updatedJson = JSON.stringify(backupCodes);
const storedValue = userDataKey
? FieldCrypto.encryptField(
updatedJson,
userDataKey,
userRecord.id,
"totpBackupCodes",
)
: updatedJson;
await createCurrentUserRepository().update(userRecord.id, {
totpBackupCodes: storedValue,
});
return true;
}
}
return false;
}
export function registerUserTotpRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
): void {
/**
* @openapi
* /users/totp/setup:
* post:
* summary: Setup TOTP
* description: Initiates TOTP setup by generating a secret and QR code.
* tags:
* - Users
* responses:
* 200:
* description: TOTP setup initiated with secret and QR code.
* 400:
* description: TOTP is already enabled.
* 404:
* description: User not found.
* 500:
* description: Failed to setup TOTP.
*/
router.post("/totp/setup", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.totpEnabled) {
const credential = req.body?.credential;
if (!credential) {
return res.status(400).json({
error: "A TOTP code or password is required",
});
}
const userDataKey = authManager.getUserDataKey(userId);
let verified = await verifyTotpReauth(
userRecord,
credential,
userDataKey,
);
if (!verified && !userRecord.isOidc && userRecord.passwordHash) {
verified = await bcrypt.compare(credential, userRecord.passwordHash);
}
if (!verified) {
return res.status(401).json({
error: "Incorrect password or invalid TOTP code",
});
}
const existingSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userId,
"totpSecret",
)
: userRecord.totpSecret;
if (!existingSecret) {
return res.status(409).json({ error: "TOTP secret is unavailable" });
}
const otpauthUrl = speakeasy.otpauthURL({
secret: existingSecret,
label: `Termix (${userRecord.username})`,
encoding: "base32",
});
authLogger.info("Additional TOTP authenticator enrollment started", {
operation: "totp_add_authenticator",
userId,
});
return res.json({
secret: existingSecret,
qr_code: await QRCode.toDataURL(otpauthUrl),
additional: true,
});
}
const secret = speakeasy.generateSecret({
name: `Termix (${userRecord.username})`,
length: 32,
});
await createCurrentUserRepository().update(userId, {
totpSecret: secret.base32,
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
res.json({
secret: secret.base32,
qr_code: qrCodeUrl,
});
} catch (err) {
authLogger.error("Failed to setup TOTP", err);
res.status(500).json({ error: "Failed to setup TOTP" });
}
});
/**
* @openapi
* /users/totp/enable:
* post:
* summary: Enable TOTP
* description: Enables TOTP after verifying the initial code.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP enabled successfully with backup codes.
* 400:
* description: TOTP code is required or TOTP already enabled.
* 401:
* description: Invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to enable TOTP.
*/
router.post("/totp/enable", authenticateJWT, async (req, res) => {
if (isTrustedProxyAuthEnabled()) {
return res.status(409).json({
error: "TOTP is disabled while trusted proxy authentication is enabled",
});
}
const userId = (req as AuthenticatedRequest).userId;
const sessionId = (req as AuthenticatedRequest).sessionId;
const { totp_code } = req.body;
if (!totp_code) {
return res.status(400).json({ error: "TOTP code is required" });
}
try {
const passwordLoginAllowed =
await createCurrentSettingsRepository().getBoolean(
"allow_password_login",
true,
);
if (!passwordLoginAllowed) {
return res.status(409).json({
error:
"Cannot enable 2FA while password login is disabled. Enable password login first.",
});
}
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
if (!userRecord.totpSecret) {
return res.status(400).json({ error: "TOTP setup not initiated" });
}
const userDataKey = authManager.getUserDataKey(userId);
const totpSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userId,
"totpSecret",
)
: userRecord.totpSecret;
const verified = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
? FieldCrypto.encryptField(
backupCodesJson,
userDataKey,
userId,
"totpBackupCodes",
)
: backupCodesJson;
await createCurrentUserRepository().update(userId, {
totpEnabled: true,
totpBackupCodes: storedBackupCodes,
});
await createCurrentSessionRepository().revokeAllForUser(
userId,
sessionId,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
try {
await DatabaseSaveTrigger.forceSave("totp_enable_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist TOTP enablement to disk",
saveError,
{
operation: "totp_enable_db_save_failed",
userId,
},
);
}
res.json({
message: "TOTP enabled successfully",
backup_codes: backupCodes,
});
} catch (err) {
authLogger.error("Failed to enable TOTP", err);
res.status(500).json({ error: "Failed to enable TOTP" });
}
});
/**
* @openapi
* /users/totp/disable:
* post:
* summary: Disable TOTP
* description: Disables TOTP for a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP disabled successfully.
* 400:
* description: Password or TOTP code is required.
* 401:
* description: Incorrect password or invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router.post("/totp/disable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
try {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
// One re-authentication value, whichever kind it is. The dialog offers a
// single field -- "Enter TOTP code or password" -- so it arrives in
// whichever of the two body fields the caller happened to use.
const credential = totp_code || password;
if (!credential) {
return res.status(400).json({
error: userRecord.isOidc
? "A TOTP code is required"
: "A TOTP code or password is required",
});
}
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
const userDataKey = authManager.getUserDataKey(userId);
// TOTP code or backup code first; verifyTotpReauth deliberately refuses
// the account password, so that stays a separate comparison here.
let verified = await verifyTotpReauth(
userRecord,
credential,
userDataKey,
);
if (!verified && !userRecord.isOidc && userRecord.passwordHash) {
verified = await bcrypt.compare(credential, userRecord.passwordHash);
}
if (!verified) {
return res
.status(401)
.json({ error: "Incorrect password or invalid TOTP code" });
}
await createCurrentUserRepository().update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
authLogger.info("Two-factor authentication disabled", {
operation: "totp_disable",
userId,
});
res.json({ message: "TOTP disabled successfully" });
} catch (err) {
authLogger.error("Failed to disable TOTP", err);
res.status(500).json({ error: "Failed to disable TOTP" });
}
});
/**
* @openapi
* /users/totp/backup-codes:
* post:
* summary: Generate new backup codes
* description: Generates new TOTP backup codes.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: New backup codes generated.
* 400:
* description: Password or TOTP code is required.
* 401:
* description: Incorrect password or invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to generate backup codes.
*/
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
try {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (!totp_code || (!userRecord.isOidc && !password)) {
return res.status(400).json({
error: userRecord.isOidc
? "A TOTP code is required"
: "Both password and TOTP code are required",
});
}
if (
!userRecord.isOidc &&
(!userRecord.passwordHash ||
!(await bcrypt.compare(password, userRecord.passwordHash)))
) {
return res.status(401).json({ error: "Incorrect password" });
}
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
const userDataKey = authManager.getUserDataKey(userId);
const verified = await verifyTotpReauth(
userRecord,
totp_code,
userDataKey,
);
if (!verified) {
return res
.status(401)
.json({ error: "Incorrect password or invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
? FieldCrypto.encryptField(
backupCodesJson,
userDataKey,
userId,
"totpBackupCodes",
)
: backupCodesJson;
await createCurrentUserRepository().update(userId, {
totpBackupCodes: storedBackupCodes,
});
res.json({ backup_codes: backupCodes });
} catch (err) {
authLogger.error("Failed to generate backup codes", err);
res.status(500).json({ error: "Failed to generate backup codes" });
}
});
/**
* @openapi
* /users/totp/verify-login:
* post:
* summary: Verify TOTP during login
* description: Verifies the TOTP code during login.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* temp_token:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP verification successful.
* 400:
* description: Token and TOTP code are required.
* 401:
* description: Invalid temporary token or TOTP code.
* 404:
* description: User not found.
* 500:
* description: TOTP verification failed.
*/
router.post("/totp/verify-login", async (req, res) => {
const { temp_token, totp_code, rememberMe } = req.body;
if (!temp_token || !totp_code) {
return res
.status(400)
.json({ error: "Token and TOTP code are required" });
}
try {
const decoded = await authManager.verifyJWTToken(temp_token);
if (!decoded || !decoded.pendingTOTP) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const userRecord = await createCurrentUserRepository().findById(
decoded.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
if (lockStatus.locked) {
authLogger.warn("TOTP verification blocked due to rate limiting", {
operation: "totp_verify_blocked",
userId: userRecord.id,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
remainingTime: lockStatus.remainingTime,
code: "TOTP_RATE_LIMITED",
});
}
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
if (!userRecord.totpEnabled || !userRecord.totpSecret) {
return res
.status(400)
.json({ error: "TOTP not enabled for this user" });
}
const userDataKey = authManager.getUserDataKey(userRecord.id);
if (!userDataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userRecord.id,
"totp_secret",
);
if (!totpSecret) {
await createCurrentUserRepository().update(userRecord.id, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
return res.status(400).json({
error:
"TOTP has been disabled due to password reset. Please set up TOTP again.",
});
}
const verified = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
let backupCodes = [];
try {
backupCodes = userRecord.totpBackupCodes
? JSON.parse(userRecord.totpBackupCodes)
: [];
} catch {
backupCodes = [];
}
if (!Array.isArray(backupCodes)) {
backupCodes = [];
}
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
authLogger.warn("TOTP verification failed - invalid code", {
operation: "totp_verify_failed",
userId: userRecord.id,
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
return res.status(401).json({
error: "Invalid TOTP code",
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
}
backupCodes.splice(backupIndex, 1);
await createCurrentUserRepository().update(userRecord.id, {
totpBackupCodes: JSON.stringify(backupCodes),
});
}
loginRateLimiter.resetTOTPAttempts(userRecord.id);
const deviceInfo = parseUserAgent(req);
if (rememberMe) {
const deviceFingerprint = generateDeviceFingerprint(
deviceInfo,
getDeviceId(req),
);
if (deviceFingerprint) {
await authManager.addTrustedDevice(
userRecord.id,
deviceFingerprint,
deviceInfo.type,
deviceInfo.deviceInfo,
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: userRecord.id,
deviceType: deviceInfo.type,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
rememberMe: !!rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
authLogger.success("TOTP verification successful", {
operation: "totp_verify_success",
userId: userRecord.id,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const response: Record<string, unknown> = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
};
const timeoutValue = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24;
const maxAge = rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json(response);
} catch (err) {
authLogger.error("TOTP verification failed", err);
return res.status(500).json({ error: "TOTP verification failed" });
}
});
}
@@ -1,590 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Router } from "express";
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
AuthenticatorTransportFuture,
Base64URLString,
WebAuthnCredential,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { nanoid } from "nanoid";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import {
createCurrentUserRepository,
createCurrentWebauthnCredentialRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
type UserVerification = "discouraged" | "preferred" | "required";
type NativeAppRequestChecker = (req: Request) => boolean;
interface WebAuthnRoutesDeps {
authenticateJWT: RequestHandler;
authManager: AuthManager;
isNativeAppRequest: NativeAppRequestChecker;
}
interface ChallengeRecord {
challenge: string;
userId?: string;
rpID: string;
origin: string;
userVerification: UserVerification;
createdAt: number;
}
const challengeTtlMs = 5 * 60 * 1000;
const registrationChallenges = new Map<string, ChallengeRecord>();
const authenticationChallenges = new Map<string, ChallengeRecord>();
function normalizeUserVerification(value: unknown): UserVerification {
return value === "discouraged" || value === "required" ? value : "preferred";
}
function getRequestOrigin(req: Request): string {
const origin = req.get("origin");
if (origin) return origin;
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
}
function getRpID(origin: string): string {
return new URL(origin).hostname;
}
function pruneChallenges(map: Map<string, ChallengeRecord>): void {
const now = Date.now();
for (const [id, record] of map) {
if (now - record.createdAt > challengeTtlMs) {
map.delete(id);
}
}
}
function putChallenge(
map: Map<string, ChallengeRecord>,
record: Omit<ChallengeRecord, "createdAt">,
): string {
pruneChallenges(map);
const challengeId = nanoid();
map.set(challengeId, { ...record, createdAt: Date.now() });
return challengeId;
}
function takeChallenge(
map: Map<string, ChallengeRecord>,
challengeId: unknown,
): ChallengeRecord | null {
if (typeof challengeId !== "string") return null;
pruneChallenges(map);
const record = map.get(challengeId);
if (!record) return null;
map.delete(challengeId);
return record;
}
function toBase64Url(value: Uint8Array): string {
return Buffer.from(value).toString("base64url");
}
function fromBase64Url(value: string): Uint8Array {
return Uint8Array.from(Buffer.from(value, "base64url"));
}
function parseTransports(value: string | null): AuthenticatorTransportFuture[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function getCredentialForVerification(
credential: WebauthnCredentialRecord,
): WebAuthnCredential {
return {
id: credential.credentialId as Base64URLString,
publicKey: fromBase64Url(
credential.publicKey,
) as WebAuthnCredential["publicKey"],
counter: credential.counter,
transports: parseTransports(credential.transports),
};
}
export function registerUserWebAuthnRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
): void {
/**
* @openapi
* /users/webauthn/credentials:
* get:
* summary: List passkeys
* description: Lists the authenticated user's registered passkeys.
* tags:
* - WebAuthn
* responses:
* 200:
* description: List of passkeys.
* 401:
* description: Authentication required.
*/
router.get("/webauthn/credentials", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
res.json({
credentials: credentials.map((credential) => ({
id: credential.id,
name: credential.name,
deviceType: credential.deviceType,
backedUp: credential.backedUp,
transports: parseTransports(credential.transports),
userVerification: credential.userVerification,
createdAt: credential.createdAt,
lastUsedAt: credential.lastUsedAt,
})),
});
});
/**
* @openapi
* /users/webauthn/register/options:
* post:
* summary: Start passkey registration
* description: Generates WebAuthn registration options for the authenticated user.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Registration options and challenge id.
* 401:
* description: Authentication required.
* 404:
* description: User not found.
*/
router.post(
"/webauthn/register/options",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!authManager.getUserDataKey(userId)) {
return res.status(401).json({
error: "User data is locked. Log in again before adding a passkey.",
});
}
const user = await createCurrentUserRepository().findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const existing =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
const origin = getRequestOrigin(req);
const rpID = getRpID(origin);
const userVerification = normalizeUserVerification(
req.body?.userVerification,
);
const options = await generateRegistrationOptions({
rpName: "Termix",
rpID,
userID: Buffer.from(userId, "utf8"),
userName: user.username,
userDisplayName: user.username,
attestationType: "none",
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
})),
authenticatorSelection: {
residentKey: "required",
userVerification,
},
});
const challengeId = putChallenge(registrationChallenges, {
challenge: options.challenge,
userId,
rpID,
origin,
userVerification,
});
res.json({ options, challengeId });
},
);
/**
* @openapi
* /users/webauthn/register/verify:
* post:
* summary: Finish passkey registration
* description: Verifies the WebAuthn registration response and stores the passkey.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Passkey registered.
* 400:
* description: Registration failed or challenge expired.
* 401:
* description: Authentication required.
*/
router.post(
"/webauthn/register/verify",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const challenge = takeChallenge(
registrationChallenges,
req.body?.challengeId,
);
if (!challenge || challenge.userId !== userId) {
return res
.status(400)
.json({ error: "Registration challenge expired" });
}
try {
const verification = await verifyRegistrationResponse({
response: req.body?.response as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
requireUserVerification: challenge.userVerification === "required",
});
if (!verification.verified) {
return res.status(400).json({ error: "Passkey registration failed" });
}
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const transports =
(req.body?.response as RegistrationResponseJSON | undefined)?.response
?.transports ?? [];
const name =
typeof req.body?.name === "string" && req.body.name.trim()
? req.body.name.trim().slice(0, 80)
: "Passkey";
await createCurrentWebauthnCredentialRepository().create({
id: nanoid(),
userId,
name,
credentialId: credential.id,
publicKey: toBase64Url(credential.publicKey),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: JSON.stringify(transports),
userVerification: challenge.userVerification,
createdAt: new Date().toISOString(),
});
res.json({ success: true });
} catch (error) {
authLogger.warn("WebAuthn registration failed", {
operation: "webauthn_register_verify",
userId,
error: getErrorMessage(error, "Unknown"),
});
res.status(400).json({ error: "Passkey registration failed" });
}
},
);
/**
* @openapi
* /users/webauthn/authenticate/options:
* post:
* summary: Start passkey login
* description: Generates WebAuthn authentication options, optionally scoped to a username.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Authentication options and challenge id.
* 404:
* description: No passkeys found for the user.
*/
router.post("/webauthn/authenticate/options", async (req, res) => {
const origin = getRequestOrigin(req);
const rpID = getRpID(origin);
const userVerification = normalizeUserVerification(
req.body?.userVerification,
);
const username =
typeof req.body?.username === "string" ? req.body.username.trim() : "";
let userId: string | undefined;
let allowCredentials:
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
| undefined;
if (username) {
const user = await createCurrentUserRepository().findByUsername(username);
if (!user) {
return res.status(404).json({ error: "No passkeys found" });
}
userId = user.id;
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
if (!credentials.length) {
return res.status(404).json({ error: "No passkeys found" });
}
allowCredentials = credentials.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification,
});
const challengeId = putChallenge(authenticationChallenges, {
challenge: options.challenge,
userId,
rpID,
origin,
userVerification,
});
res.json({ options, challengeId });
});
/**
* @openapi
* /users/webauthn/authenticate/verify:
* post:
* summary: Finish passkey login
* description: Verifies the WebAuthn assertion and issues a session token. Verified WebAuthn user verification satisfies 2FA; possession-only keys retain the configured TOTP challenge.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Login succeeded.
* 400:
* description: Challenge expired or invalid response.
* 401:
* description: Passkey not recognized or authentication failed.
*/
router.post("/webauthn/authenticate/verify", async (req, res) => {
const challenge = takeChallenge(
authenticationChallenges,
req.body?.challengeId,
);
if (!challenge) {
return res
.status(400)
.json({ error: "Authentication challenge expired" });
}
const response = req.body?.response as
AuthenticationResponseJSON | undefined;
if (!response?.id) {
return res.status(400).json({ error: "Invalid passkey response" });
}
const credential =
await createCurrentWebauthnCredentialRepository().findByCredentialId(
response.id,
);
if (!credential) {
return res.status(401).json({ error: "Passkey not recognized" });
}
if (challenge.userId && challenge.userId !== credential.userId) {
return res.status(401).json({ error: "Passkey not recognized" });
}
try {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
credential: getCredentialForVerification(credential),
requireUserVerification: challenge.userVerification === "required",
advancedFIDOConfig: {
userVerification: challenge.userVerification,
},
});
if (!verification.verified) {
return res.status(401).json({ error: "Passkey authentication failed" });
}
const userRecord = await createCurrentUserRepository().findById(
credential.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const deviceInfo = parseUserAgent(req);
const authenticated = await authManager.authenticateWebAuthnUser(
userRecord.id,
deviceInfo.type,
);
if (!authenticated) {
return res.status(401).json({
error:
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
});
}
await createCurrentWebauthnCredentialRepository().updateAuthState(
credential.id,
{
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
},
);
if (
userRecord.totpEnabled &&
verification.authenticationInfo.userVerified !== true
) {
const deviceFingerprint = generateDeviceFingerprint(
deviceInfo,
getDeviceId(req),
);
const isTrusted = deviceFingerprint
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
: false;
if (!isTrusted) {
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
expiresIn: "10m",
});
return res.json({
success: true,
requires_totp: true,
temp_token: tempToken,
rememberMe: !!req.body?.rememberMe,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
rememberMe: !!req.body?.rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const timeoutSetting = getCurrentSettingValue("session_timeout_hours");
const timeoutHours = timeoutSetting
? parseInt(timeoutSetting, 10) || 24
: 24;
const maxAge = req.body?.rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge));
res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
});
} catch (error) {
authLogger.warn("WebAuthn authentication failed", {
operation: "webauthn_auth_verify",
credentialId: credential.id,
userId: credential.userId,
error: getErrorMessage(error, "Unknown"),
});
res.status(401).json({ error: "Passkey authentication failed" });
}
});
/**
* @openapi
* /users/webauthn/credentials/{credentialId}:
* delete:
* summary: Delete a passkey
* description: Removes one of the authenticated user's passkeys.
* tags:
* - WebAuthn
* parameters:
* - in: path
* name: credentialId
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Passkey deleted.
* 401:
* description: Authentication required.
*/
router.delete(
"/webauthn/credentials/:credentialId",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const credentialId = String(req.params.credentialId);
await createCurrentWebauthnCredentialRepository().deleteForUser(
userId,
credentialId,
);
res.json({ success: true });
},
);
}
File diff suppressed because it is too large. Load diff
-448
View File
@@ -1,448 +0,0 @@
import express, { type Request, type Response } from "express";
import {
createCurrentVaultProfileRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { completeVaultAuth } from "../../hosts/vault-oidc-auth.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
async function userIsAdmin(userId: string): Promise<boolean> {
try {
const user = await createCurrentUserRepository().findById(userId);
return !!user?.isAdmin;
} catch {
return false;
}
}
function formatProfile(
row: Record<string, unknown>,
currentUserId: string,
): Record<string, unknown> {
return {
id: row.id,
name: row.name,
description: row.description,
folder: row.folder,
tags:
typeof row.tags === "string"
? row.tags
? (row.tags as string).split(",").filter(Boolean)
: []
: [],
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace,
oidcMount: row.oidcMount,
oidcRole: row.oidcRole,
sshMount: row.sshMount,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals,
keyType: row.keyType,
shared: !!row.shared,
owned: row.userId === currentUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* @openapi
* /vault/oidc/callback:
* get:
* summary: Vault OIDC callback
* description: Unauthenticated endpoint the IdP redirects to after login. Correlates the authorization code to a pending session via the Vault-issued state parameter.
* tags:
* - Vault
* parameters:
* - in: query
* name: state
* required: true
* schema:
* type: string
* - in: query
* name: code
* required: true
* schema:
* type: string
* - in: query
* name: error
* schema:
* type: string
* responses:
* 200:
* description: HTML page confirming sign-in success or failure.
* 400:
* description: Missing parameters or authentication failure.
*/
router.get("/oidc/callback", async (req: Request, res: Response) => {
const state = String(req.query.state || "");
const code = String(req.query.code || "");
const oidcError = req.query.error ? String(req.query.error) : "";
const esc = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const html = (title: string, message: string) =>
`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
<style>body{font-family:system-ui,sans-serif;background:#0b0b0c;color:#e5e5e5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{max-width:420px;text-align:center;padding:24px;border:1px solid #2a2a2e;border-radius:8px}</style></head>
<body><div class="card"><h2>${esc(title)}</h2><p>${esc(message)}</p>
<script>setTimeout(function(){window.close()},1500)</script></div></body></html>`;
if (oidcError) {
return res
.status(400)
.send(html("Vault sign-in failed", `Vault returned: ${oidcError}`));
}
if (!state || !code) {
return res
.status(400)
.send(html("Vault sign-in failed", "Missing state or code."));
}
const result = await completeVaultAuth(state, code);
if (result.ok) {
return res.send(
html(
"Vault sign-in complete",
"You can close this window and return to Termix.",
),
);
}
return res
.status(400)
.send(
html("Vault sign-in failed", result.error || "Authentication failed."),
);
});
/**
* @openapi
* /vault/profiles:
* get:
* summary: List Vault profiles
* description: Returns all Vault signer profiles owned by the authenticated user or marked as shared.
* tags:
* - Vault
* responses:
* 200:
* description: Array of Vault profile objects.
* 500:
* description: Failed to list vault profiles.
*/
router.get(
"/profiles",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentVaultProfileRepository().listVisibleToUser(userId);
res.json(
rows.map((r) => formatProfile(r as Record<string, unknown>, userId)),
);
} catch (err) {
authLogger.error("Failed to list vault profiles", err);
res.status(500).json({ error: "Failed to list vault profiles" });
}
},
);
/**
* @openapi
* /vault/profiles:
* post:
* summary: Create a Vault profile
* description: Creates a new Vault signer profile owned by the authenticated user. The shared flag requires admin privileges.
* tags:
* - Vault
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - vaultAddr
* - sshRole
* properties:
* name:
* type: string
* vaultAddr:
* type: string
* vaultNamespace:
* type: string
* oidcMount:
* type: string
* oidcRole:
* type: string
* sshMount:
* type: string
* sshRole:
* type: string
* validPrincipals:
* type: string
* keyType:
* type: string
* shared:
* type: boolean
* responses:
* 201:
* description: Created Vault profile object.
* 400:
* description: Missing required fields.
* 403:
* description: Non-admin attempted to create a shared profile.
* 500:
* description: Failed to create vault profile.
*/
router.post(
"/profiles",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
name,
description,
folder,
tags,
vaultAddr,
vaultNamespace,
oidcMount,
oidcRole,
sshMount,
sshRole,
validPrincipals,
keyType,
shared,
} = req.body;
if (
!isNonEmptyString(name) ||
!isNonEmptyString(vaultAddr) ||
!isNonEmptyString(sshRole)
) {
return res
.status(400)
.json({ error: "name, vaultAddr and sshRole are required" });
}
const wantShared = !!shared;
if (wantShared && !(await userIsAdmin(userId))) {
return res.status(403).json({
error: "Only administrators can create shared Vault profiles",
});
}
try {
const inserted = await createCurrentVaultProfileRepository().create({
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
vaultAddr: vaultAddr.trim(),
vaultNamespace: vaultNamespace?.trim() || null,
oidcMount: oidcMount?.trim() || null,
oidcRole: oidcRole?.trim() || null,
sshMount: sshMount?.trim() || null,
sshRole: sshRole.trim(),
validPrincipals: validPrincipals?.trim() || null,
keyType: keyType?.trim() || null,
shared: wantShared,
});
res
.status(201)
.json(formatProfile(inserted as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to create vault profile", err);
res.status(500).json({ error: "Failed to create vault profile" });
}
},
);
/**
* @openapi
* /vault/profiles/{id}:
* put:
* summary: Update a Vault profile
* description: Updates a Vault signer profile. Only the owner may edit; toggling shared to true requires admin privileges.
* tags:
* - Vault
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Updated Vault profile object.
* 400:
* description: Invalid profile id.
* 403:
* description: Non-owner attempted to edit, or non-admin attempted to share.
* 404:
* description: Profile not found.
* 500:
* description: Failed to update vault profile.
*/
router.put(
"/profiles/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ error: "Invalid profile id" });
}
try {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can edit this profile" });
}
const body = req.body;
const fields: VaultProfileUpdateInput = {
updatedAt: new Date().toISOString(),
};
if (body.name !== undefined) fields.name = body.name?.trim();
if (body.description !== undefined)
fields.description = body.description?.trim() || null;
if (body.folder !== undefined)
fields.folder = body.folder?.trim() || null;
if (body.tags !== undefined)
fields.tags = Array.isArray(body.tags)
? body.tags.join(",")
: body.tags || "";
if (body.vaultAddr !== undefined && isNonEmptyString(body.vaultAddr))
fields.vaultAddr = body.vaultAddr.trim();
if (body.vaultNamespace !== undefined)
fields.vaultNamespace = body.vaultNamespace?.trim() || null;
if (body.oidcMount !== undefined)
fields.oidcMount = body.oidcMount?.trim() || null;
if (body.oidcRole !== undefined)
fields.oidcRole = body.oidcRole?.trim() || null;
if (body.sshMount !== undefined)
fields.sshMount = body.sshMount?.trim() || null;
if (body.sshRole !== undefined && isNonEmptyString(body.sshRole))
fields.sshRole = body.sshRole.trim();
if (body.validPrincipals !== undefined)
fields.validPrincipals = body.validPrincipals?.trim() || null;
if (body.keyType !== undefined)
fields.keyType = body.keyType?.trim() || null;
if (body.shared !== undefined) {
if (!!body.shared && !(await userIsAdmin(userId))) {
return res.status(403).json({
error: "Only administrators can share Vault profiles",
});
}
fields.shared = !!body.shared;
}
const updated = await repository.updateById(id, fields);
if (!updated) {
return res.status(404).json({ error: "Profile not found" });
}
res.json(formatProfile(updated as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to update vault profile", err);
res.status(500).json({ error: "Failed to update vault profile" });
}
},
);
/**
* @openapi
* /vault/profiles/{id}:
* delete:
* summary: Delete a Vault profile
* description: Permanently deletes a Vault signer profile. Only the owner may delete it.
* tags:
* - Vault
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deletion confirmed.
* 400:
* description: Invalid profile id.
* 403:
* description: Non-owner attempted to delete.
* 404:
* description: Profile not found.
* 500:
* description: Failed to delete vault profile.
*/
router.delete(
"/profiles/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ error: "Invalid profile id" });
}
try {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
const deleted = await repository.deleteById(id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"vaultProfiles",
deleted.syncId,
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete vault profile", err);
res.status(500).json({ error: "Failed to delete vault profile" });
}
},
);
export default router;
-638
View File
@@ -1,638 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentWorkspaceRepository } from "../repositories/factory.js";
import type { WorkspaceRecord } from "../repositories/workspace-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function parseWorkspaceId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) ? id : null;
}
function isValidPayload(val: unknown): val is Record<string, unknown> {
return (
typeof val === "object" &&
val !== null &&
Array.isArray((val as Record<string, unknown>).tabs)
);
}
function serialize(record: WorkspaceRecord) {
let payload: unknown;
try {
payload = JSON.parse(record.payload || "{}");
} catch {
payload = { version: 1, tabs: [] };
}
const tabs = Array.isArray((payload as { tabs?: unknown[] })?.tabs)
? (payload as { tabs: unknown[] }).tabs
: [];
return {
...record,
payload,
tabCount: tabs.length,
};
}
/**
* @openapi
* /workspaces:
* get:
* summary: List the current user's saved workspaces
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
* tags:
* - Workspaces
* responses:
* 200:
* description: List of workspaces.
*/
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const records =
await createCurrentWorkspaceRepository().listByUser(userId);
res.json(records.map(serialize));
} catch (err) {
databaseLogger.error("Failed to list workspaces", err, {
operation: "workspace_list_failed",
userId,
});
res.status(500).json({ error: "Failed to list workspaces" });
}
},
);
/**
* @openapi
* /workspaces:
* post:
* summary: Save the current tab arrangement as a new named workspace
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* color:
* type: string
* icon:
* type: string
* payload:
* type: object
* responses:
* 200:
* description: Workspace created.
* 400:
* description: Invalid request body.
*/
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, payload } = req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Workspace name is required" });
}
if (!isValidPayload(payload)) {
return res
.status(400)
.json({ error: "payload with a tabs array is required" });
}
try {
const created = await createCurrentWorkspaceRepository().create(userId, {
name: name.trim(),
color: isNonEmptyString(color) ? color : null,
icon: isNonEmptyString(icon) ? icon : null,
payload: JSON.stringify(payload),
});
res.json(serialize(created));
} catch (err) {
databaseLogger.error("Failed to create workspace", err, {
operation: "workspace_create_failed",
userId,
});
res.status(500).json({ error: "Failed to create workspace" });
}
},
);
/**
* @openapi
* /workspaces/last-session:
* get:
* summary: Fetch the auto-maintained "Last Session" workspace
* description: Returns null if the current session has never been auto-saved yet.
* tags:
* - Workspaces
* responses:
* 200:
* description: The Last Session workspace, or null.
*/
router.get(
"/last-session",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const record =
await createCurrentWorkspaceRepository().findLastSession(userId);
res.json(record ? serialize(record) : null);
} catch (err) {
databaseLogger.error("Failed to fetch last session workspace", err, {
operation: "workspace_last_session_get_failed",
userId,
});
res.status(500).json({ error: "Failed to fetch last session workspace" });
}
},
);
/**
* @openapi
* /workspaces/last-session:
* put:
* summary: Upsert the auto-maintained "Last Session" workspace
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Last Session workspace saved.
* 400:
* description: Invalid request body.
*/
router.put(
"/last-session",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { payload } = req.body ?? {};
if (!isValidPayload(payload)) {
return res
.status(400)
.json({ error: "payload with a tabs array is required" });
}
try {
const saved = await createCurrentWorkspaceRepository().upsertLastSession(
userId,
JSON.stringify(payload),
);
res.json(serialize(saved));
} catch (err) {
databaseLogger.error("Failed to save last session workspace", err, {
operation: "workspace_last_session_save_failed",
userId,
});
res.status(500).json({ error: "Failed to save last session workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}:
* patch:
* summary: Rename or recolor a workspace
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace updated.
* 400:
* description: Invalid request, or target is the Last Session workspace.
* 404:
* description: Workspace not found.
*/
router.patch(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
const { name, color, icon } = req.body ?? {};
if (name !== undefined && !isNonEmptyString(name)) {
return res.status(400).json({ error: "Workspace name cannot be empty" });
}
try {
const updated = await createCurrentWorkspaceRepository().update(
userId,
id,
{
name: name !== undefined ? name.trim() : undefined,
color,
icon,
},
);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to update workspace", err, {
operation: "workspace_update_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to update workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}/content:
* put:
* summary: Overwrite a workspace's saved tab arrangement with a new payload
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Workspace content updated.
* 400:
* description: Invalid request body.
* 404:
* description: Workspace not found.
*/
router.put(
"/:id/content",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
const { payload } = req.body ?? {};
if (!isValidPayload(payload)) {
return res
.status(400)
.json({ error: "payload with a tabs array is required" });
}
try {
const updated = await createCurrentWorkspaceRepository().updateContent(
userId,
id,
JSON.stringify(payload),
);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to update workspace content", err, {
operation: "workspace_content_update_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to update workspace content" });
}
},
);
/**
* @openapi
* /workspaces/{id}/duplicate:
* post:
* summary: Duplicate a workspace's content and color/icon under a new name
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: New workspace created from the duplicate.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/duplicate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
const { name } = req.body ?? {};
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Workspace name is required" });
}
try {
const repository = createCurrentWorkspaceRepository();
const source = await repository.findById(userId, id);
if (!source) {
return res.status(404).json({ error: "Workspace not found" });
}
const created = await repository.create(userId, {
name: name.trim(),
color: source.color,
icon: source.icon,
payload: source.payload,
});
res.json(serialize(created));
} catch (err) {
databaseLogger.error("Failed to duplicate workspace", err, {
operation: "workspace_duplicate_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to duplicate workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}/set-default:
* post:
* summary: Mark a workspace as the restore-on-login default
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace set as default.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/set-default",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const updated = await createCurrentWorkspaceRepository().setDefault(
userId,
id,
);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to set default workspace", err, {
operation: "workspace_set_default_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to set default workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}/unset-default:
* post:
* summary: Remove a workspace as the restore-on-login default
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace unset as default.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/unset-default",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const updated = await createCurrentWorkspaceRepository().unsetDefault(
userId,
id,
);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to unset default workspace", err, {
operation: "workspace_unset_default_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to unset default workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}/apply:
* post:
* summary: Fetch a workspace to apply and mark it as just used
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The workspace to apply.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/apply",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const repository = createCurrentWorkspaceRepository();
const record = await repository.findById(userId, id);
if (!record) {
return res.status(404).json({ error: "Workspace not found" });
}
await repository.touchLastUsed(userId, id);
res.json(serialize(record));
} catch (err) {
databaseLogger.error("Failed to apply workspace", err, {
operation: "workspace_apply_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to apply workspace" });
}
},
);
/**
* @openapi
* /workspaces/{id}:
* delete:
* summary: Delete a workspace
* description: Rejects the Last Session workspace, which is not user-deletable.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace deleted.
* 404:
* description: Workspace not found.
*/
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const deleted = await createCurrentWorkspaceRepository().delete(
userId,
id,
);
if (!deleted) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to delete workspace", err, {
operation: "workspace_delete_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to delete workspace" });
}
},
);
export default router;