mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
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:
4405 files changed
+1341026
-289722
No files matched your search
@@ -1,38 +0,0 @@
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { sshLogger } from "./logger.js";
|
||||
|
||||
const METRICS_SERVICE_URL = "http://localhost:30005";
|
||||
|
||||
export async function triggerLoginAlert(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const token = await SystemCrypto.getInstance().getInternalAuthToken();
|
||||
const response = await fetch(
|
||||
`${METRICS_SERVICE_URL}/internal/login-alert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-auth": token,
|
||||
},
|
||||
body: JSON.stringify({ hostId, userId, sshUser, fromIp }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
throw new Error(
|
||||
`Metrics service returned ${response.status}${details ? `: ${details}` : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to trigger login alert", {
|
||||
operation: "login_alert_trigger_error",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { Logger } from "./logger.js";
|
||||
|
||||
export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||
|
||||
const FEATURE_ACTIVITY_TYPES = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
"server_stats",
|
||||
] as const;
|
||||
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function getTelemetryEnvOverride(): boolean | null {
|
||||
const envVal = process.env.ENABLE_TELEMETRY;
|
||||
if (envVal === undefined) return null;
|
||||
const normalized = envVal.trim().toLowerCase();
|
||||
if (normalized === "") return null;
|
||||
return normalized === "true";
|
||||
}
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
const override = getTelemetryEnvOverride();
|
||||
if (override !== null) return override;
|
||||
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const existing = await settings.get("analytics_instance_id");
|
||||
if (existing) return existing;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await settings.set("analytics_instance_id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
return process.env.VERSION || "unknown";
|
||||
}
|
||||
|
||||
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: recentActivity.type,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||
.groupBy(recentActivity.type);
|
||||
|
||||
const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
|
||||
const usage: Record<string, number> = {};
|
||||
for (const type of FEATURE_ACTIVITY_TYPES) {
|
||||
usage[`used_${type}`] = counts.get(type) ?? 0;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function collectCounts(): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const [userRows, hostRows] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||
]);
|
||||
|
||||
return {
|
||||
userCount: Number(userRows[0]?.count ?? 0),
|
||||
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
if (!(await isAnalyticsEnabled())) return;
|
||||
|
||||
const instanceId = await getOrCreateInstanceId();
|
||||
const { userCount, hostCount } = await collectCounts();
|
||||
const featureUsage = await collectFeatureUsage();
|
||||
|
||||
await axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: apiKey,
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: instanceId,
|
||||
properties: {
|
||||
version: getAppVersion(),
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
...featureUsage,
|
||||
},
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||
operation: "analytics_heartbeat_sent",
|
||||
});
|
||||
} catch (err) {
|
||||
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||
operation: "analytics_heartbeat_failed",
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (getTelemetryEnvOverride() === false) {
|
||||
analyticsLogger.info("Telemetry disabled by ENABLE_TELEMETRY", {
|
||||
operation: "analytics_disabled_by_env",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
|
||||
operation: "analytics_disabled_no_key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void collectAndSendHeartbeat();
|
||||
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let cached: string | null | undefined;
|
||||
|
||||
function readVersion(file: string): string | null {
|
||||
try {
|
||||
const version = JSON.parse(fs.readFileSync(file, "utf8")).version;
|
||||
return typeof version === "string" && version !== "unknown"
|
||||
? version
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** This install's version: VERSION, else the nearest package.json. */
|
||||
export function getLocalVersion(): string | null {
|
||||
if (cached !== undefined) return cached;
|
||||
cached =
|
||||
process.env.VERSION ||
|
||||
readVersion(path.resolve(process.cwd(), "package.json")) ||
|
||||
readVersion(path.resolve("/app", "package.json")) ||
|
||||
readVersion(path.resolve(here, "../../../package.json")) ||
|
||||
readVersion(path.resolve(here, "../../../../package.json")) ||
|
||||
null;
|
||||
return cached;
|
||||
}
|
||||
@@ -23,10 +23,8 @@ interface RetainedTable {
|
||||
}
|
||||
|
||||
/**
|
||||
* `audit_logs` already denormalises `username`, so nulling `user_id` still
|
||||
* leaves a record of who acted. `session_recordings` does not, which is why the
|
||||
* column is added and backfilled before its foreign key is relaxed — otherwise
|
||||
* relaxing it would trade deleted evidence for anonymous evidence.
|
||||
* `audit_logs` denormalises `username`, so nulling `user_id` still leaves a
|
||||
* record of who acted.
|
||||
*/
|
||||
const AUDIT_LOGS: RetainedTable = {
|
||||
name: "audit_logs",
|
||||
@@ -65,50 +63,7 @@ const AUDIT_LOGS: RetainedTable = {
|
||||
`,
|
||||
};
|
||||
|
||||
const SESSION_RECORDINGS: RetainedTable = {
|
||||
name: "session_recordings",
|
||||
columns: [
|
||||
"id",
|
||||
"host_id",
|
||||
"user_id",
|
||||
"username",
|
||||
"access_id",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"duration",
|
||||
"commands",
|
||||
"dangerous_actions",
|
||||
"recording_path",
|
||||
"protocol",
|
||||
"format",
|
||||
"terminated_by_owner",
|
||||
"termination_reason",
|
||||
],
|
||||
createSql: `
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
`,
|
||||
};
|
||||
|
||||
const RETAINED_TABLES = [AUDIT_LOGS, SESSION_RECORDINGS];
|
||||
const RETAINED_TABLES = [AUDIT_LOGS];
|
||||
|
||||
export function userDeleteIsDestructive(
|
||||
sqlite: MigratableSqlite,
|
||||
@@ -132,34 +87,6 @@ export function userDeleteIsDestructive(
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(
|
||||
sqlite: MigratableSqlite,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
try {
|
||||
sqlite.prepare(`SELECT "${column}" FROM ${table} LIMIT 1`).get();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives session_recordings a username before its user_id can become null, so
|
||||
* existing rows stay attributable.
|
||||
*/
|
||||
function ensureRecordingUsername(sqlite: MigratableSqlite): void {
|
||||
if (hasColumn(sqlite, "session_recordings", "username")) return;
|
||||
|
||||
sqlite.exec(`ALTER TABLE session_recordings ADD COLUMN username TEXT;`);
|
||||
sqlite.exec(`
|
||||
UPDATE session_recordings
|
||||
SET username = (SELECT username FROM users WHERE users.id = session_recordings.user_id)
|
||||
WHERE username IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite cannot alter a foreign key in place, so the table is copied into a new
|
||||
* one with the intended constraint and swapped. Foreign keys must be off.
|
||||
@@ -187,10 +114,6 @@ export function migrateAuditRetention(sqlite: MigratableSqlite): string[] {
|
||||
if (!userDeleteIsDestructive(sqlite, table.name)) continue;
|
||||
|
||||
try {
|
||||
if (table.name === "session_recordings") {
|
||||
ensureRecordingUsername(sqlite);
|
||||
}
|
||||
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
sqlite.exec("BEGIN TRANSACTION");
|
||||
rebuildTable(sqlite, table);
|
||||
|
||||
@@ -70,13 +70,30 @@ interface RequestWithHeaders extends Request {
|
||||
|
||||
const ADMIN_TARGET_USER_HEADER = "x-admin-target-user";
|
||||
|
||||
// Data-plane routes an admin may hit on behalf of another user. Everything
|
||||
// else (TOTP, sessions, tunnels, file manager, ...) rejects the header.
|
||||
const IMPERSONATION_PATH_ALLOWLIST = [
|
||||
/^\/host\/db\//,
|
||||
/^\/credentials(\/|$)/,
|
||||
/^\/snippets(\/|$)/,
|
||||
];
|
||||
// Core data-plane routes an admin may hit on behalf of another user.
|
||||
// Everything else (TOTP, sessions, ...) rejects the header, and so does every
|
||||
// plugin route unless its manifest sets contributes.http.adminImpersonation.
|
||||
const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
||||
|
||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
export function setPluginImpersonationCheck(
|
||||
check: (pluginId: string) => boolean,
|
||||
): void {
|
||||
pluginAllowsImpersonation = check;
|
||||
}
|
||||
|
||||
/** Whether an admin may send X-Admin-Target-User to this path. */
|
||||
export function allowsAdminImpersonation(path: string): boolean {
|
||||
if (IMPERSONATION_PATH_ALLOWLIST.some((pattern) => pattern.test(path))) {
|
||||
return true;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path);
|
||||
return !!plugin && pluginAllowsImpersonation(plugin[1]);
|
||||
}
|
||||
|
||||
class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
@@ -120,7 +137,7 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
async registerOIDCUser(
|
||||
async registerExternalUser(
|
||||
userId: string,
|
||||
_sessionDurationMs?: number,
|
||||
): Promise<void> {
|
||||
@@ -137,7 +154,7 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(
|
||||
async authenticateExternalUser(
|
||||
userId: string,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
@@ -168,7 +185,11 @@ class AuthManager {
|
||||
return this.ensureUserDEK(userId);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnUser(
|
||||
/**
|
||||
* Opens the user's data key with the server-held wrapping, for a sign-in
|
||||
* that carries no password (a passkey, trusted proxy login).
|
||||
*/
|
||||
async unlockWithSystemKey(
|
||||
userId: string,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
@@ -484,7 +505,7 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
async revokeSessionsByOidc(params: {
|
||||
async revokeSessionsByExternalSession(params: {
|
||||
ssoProviderId?: number | null;
|
||||
sub?: string | null;
|
||||
sid?: string | null;
|
||||
@@ -508,7 +529,6 @@ class AuthManager {
|
||||
if (matched.length === 0) return 0;
|
||||
|
||||
const matchedIds = matched.map((s) => s.id);
|
||||
const affectedUsers = new Set(matched.map((s) => s.userId));
|
||||
|
||||
await db.delete(sessions).where(inArray(sessions.id, matchedIds));
|
||||
|
||||
@@ -832,10 +852,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
const allowed = IMPERSONATION_PATH_ALLOWLIST.some((pattern) =>
|
||||
pattern.test(path),
|
||||
);
|
||||
if (!allowed) {
|
||||
if (!allowsAdminImpersonation(path)) {
|
||||
res.status(403).json({
|
||||
error: "Impersonation is not allowed for this route",
|
||||
code: "IMPERSONATION_NOT_ALLOWED",
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { execSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
export class AutoSSLSetup {
|
||||
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
|
||||
private static readonly CERT_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.crt",
|
||||
);
|
||||
private static readonly KEY_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.key",
|
||||
);
|
||||
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
|
||||
|
||||
static async initialize(): Promise<void> {
|
||||
if (process.env.ENABLE_SSL !== "true") {
|
||||
systemLogger.info("SSL not enabled - skipping certificate generation", {
|
||||
operation: "ssl_disabled_default",
|
||||
enable_ssl: process.env.ENABLE_SSL || "undefined",
|
||||
note: "Set ENABLE_SSL=true to enable SSL certificate generation",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await this.isSSLConfigured()) {
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
systemLogger.info("SSL certificates found from entrypoint script", {
|
||||
operation: "ssl_cert_found_entrypoint",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
});
|
||||
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
} catch {
|
||||
await this.generateSSLCertificates();
|
||||
await this.setupEnvironmentVariables();
|
||||
}
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed",
|
||||
});
|
||||
|
||||
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||
operation: "ssl_fallback_http",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async isSSLConfigured(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("checkend")) {
|
||||
systemLogger.warn(
|
||||
"SSL certificate is expired or expiring soon, will regenerate",
|
||||
{
|
||||
operation: "ssl_cert_expired",
|
||||
cert_path: this.CERT_FILE,
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
systemLogger.info(
|
||||
"SSL certificate not found or invalid, will generate new one",
|
||||
{
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE,
|
||||
},
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
execSync("openssl version", { stdio: "pipe" });
|
||||
} catch {
|
||||
throw new Error(
|
||||
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
||||
|
||||
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
||||
const opensslConfig = `
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=localhost
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
DNS.4 = termix.local
|
||||
DNS.5 = *.termix.local
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
IP.3 = 0.0.0.0
|
||||
`.trim();
|
||||
|
||||
await fs.writeFile(configFile, opensslConfig);
|
||||
|
||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
execSync(
|
||||
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await fs.chmod(this.KEY_FILE, 0o600);
|
||||
await fs.chmod(this.CERT_FILE, 0o644);
|
||||
|
||||
await fs.unlink(configFile);
|
||||
|
||||
systemLogger.success("SSL certificates generated successfully", {
|
||||
operation: "ssl_cert_generated",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
valid_days: 365,
|
||||
});
|
||||
|
||||
await this.logCertificateInfo();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SSL certificate generation failed: ${getErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static async logCertificateInfo(): Promise<void> {
|
||||
try {
|
||||
const subject = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const issuer = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notAfter = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notBefore = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
systemLogger.info("SSL Certificate Information:", {
|
||||
operation: "ssl_cert_info",
|
||||
subject: subject.replace("subject=", ""),
|
||||
issuer: issuer.replace("issuer=", ""),
|
||||
valid_from: notBefore.replace("notBefore=", ""),
|
||||
valid_until: notAfter.replace("notAfter=", ""),
|
||||
note: "Certificate will auto-renew 30 days before expiration",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn("Could not retrieve certificate information", {
|
||||
operation: "ssl_cert_info_error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async setupEnvironmentVariables(): Promise<void> {
|
||||
const certPath = this.CERT_FILE;
|
||||
const keyPath = this.KEY_FILE;
|
||||
|
||||
const sslEnvVars = {
|
||||
ENABLE_SSL: "true",
|
||||
SSL_PORT: process.env.SSL_PORT || "8443",
|
||||
SSL_CERT_PATH: certPath,
|
||||
SSL_KEY_PATH: keyPath,
|
||||
SSL_DOMAIN: "localhost",
|
||||
};
|
||||
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||
} catch {
|
||||
// expected - env file may not exist yet
|
||||
}
|
||||
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (regex.test(updatedContent)) {
|
||||
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
if (!updatedContent.includes(`# SSL Configuration`)) {
|
||||
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
||||
}
|
||||
updatedContent += `${key}=${value}\n`;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges || !envContent) {
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
|
||||
|
||||
systemLogger.info("SSL environment variables configured", {
|
||||
operation: "ssl_env_configured",
|
||||
file: this.ENV_FILE,
|
||||
variables: Object.keys(sslEnvVars),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
static getSSLConfig() {
|
||||
return {
|
||||
enabled: process.env.ENABLE_SSL === "true",
|
||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||
domain: process.env.SSL_DOMAIN || "localhost",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { alertRuleChannels, alertRules } from "../../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const MIGRATION_FLAG = "alert_rules_to_automations_v1";
|
||||
|
||||
export interface AutomationsMigrationResult {
|
||||
migrated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries existing alert rules over into automations.
|
||||
*
|
||||
* Each rule becomes an automation whose trigger is the rule's threshold or
|
||||
* state change and whose only step notifies the channels that rule was linked
|
||||
* to, which is exactly what the alert engine did with it.
|
||||
*
|
||||
* The alert_* tables are deliberately left in place. They are the rollback
|
||||
* path, and alert_firings is user-visible history; nothing writes to them once
|
||||
* the automations engine takes over.
|
||||
*/
|
||||
export async function runAutomationsMigration(): Promise<AutomationsMigrationResult | null> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
try {
|
||||
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||
// Already migrated on an earlier boot; the automations engine owns
|
||||
// evaluation from here, so the old one must stay quiet.
|
||||
await standDownAlertEngine();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { drizzle } = createCurrentRepositoryContext();
|
||||
const rules = await drizzle.select().from(alertRules);
|
||||
|
||||
if (rules.length === 0) {
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
await standDownAlertEngine();
|
||||
return { migrated: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const trigger = triggerForRule(rule);
|
||||
if (!trigger) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const links = await drizzle
|
||||
.select({ channelId: alertRuleChannels.channelId })
|
||||
.from(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, rule.id));
|
||||
const channelIds = links.map((link) => link.channelId);
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
id: "notify",
|
||||
type: "notify",
|
||||
channelIds,
|
||||
title: `${rule.name}`,
|
||||
body: messageTemplateFor(rule.triggerType),
|
||||
severity: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
const definition: AutomationDefinition = {
|
||||
version: AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps,
|
||||
};
|
||||
|
||||
await repository.create({
|
||||
userId: rule.userId,
|
||||
name: rule.name,
|
||||
description: "Migrated from an alert rule",
|
||||
enabled: !!rule.enabled,
|
||||
definition: JSON.stringify(definition),
|
||||
channels: channelIds,
|
||||
});
|
||||
migrated++;
|
||||
}
|
||||
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
await standDownAlertEngine();
|
||||
|
||||
if (migrated > 0) {
|
||||
databaseLogger.info(`Migrated ${migrated} alert rule(s) to automations`, {
|
||||
operation: "automations_migration",
|
||||
migrated,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
|
||||
return { migrated, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Alert rule migration failed", {
|
||||
operation: "automations_migration_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imported lazily: this migration runs before the metrics subsystem is loaded,
|
||||
* and pulling that module in early would drag its timers in with it.
|
||||
*/
|
||||
async function standDownAlertEngine(): Promise<void> {
|
||||
try {
|
||||
const { markAlertEngineSuperseded } =
|
||||
await import("../../hosts/metrics/alert-engine.js");
|
||||
markAlertEngineSuperseded();
|
||||
} catch {
|
||||
// If it cannot be loaded there is nothing running to stand down.
|
||||
}
|
||||
}
|
||||
|
||||
type AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||
|
||||
function triggerForRule(rule: AlertRuleRecord): Trigger | null {
|
||||
const hostSelector =
|
||||
rule.hostId === null
|
||||
? ({ kind: "all" } as const)
|
||||
: ({ kind: "host", hostId: rule.hostId } as const);
|
||||
const cooldownMinutes = rule.cooldownMinutes ?? 15;
|
||||
|
||||
switch (rule.triggerType) {
|
||||
case "cpu_threshold":
|
||||
case "memory_threshold":
|
||||
case "disk_threshold": {
|
||||
const path =
|
||||
rule.triggerType === "cpu_threshold"
|
||||
? "cpu.percent"
|
||||
: rule.triggerType === "memory_threshold"
|
||||
? "memory.percent"
|
||||
: "disk.percent";
|
||||
return {
|
||||
kind: "metric_threshold",
|
||||
hostSelector,
|
||||
metric: { path } as Extract<
|
||||
Trigger,
|
||||
{ kind: "metric_threshold" }
|
||||
>["metric"],
|
||||
// The old engine only ever compared with >=.
|
||||
operator: ">=",
|
||||
value: rule.thresholdValue ?? 0,
|
||||
forSeconds: rule.thresholdDurationSeconds ?? undefined,
|
||||
cooldownMinutes,
|
||||
};
|
||||
}
|
||||
case "host_offline":
|
||||
return {
|
||||
kind: "host_status",
|
||||
hostSelector,
|
||||
to: "offline",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "host_online":
|
||||
return {
|
||||
kind: "host_status",
|
||||
hostSelector,
|
||||
to: "online",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "health_check_failure":
|
||||
return {
|
||||
kind: "health_check",
|
||||
hostSelector,
|
||||
to: "failing",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "health_check_recovery":
|
||||
return {
|
||||
kind: "health_check",
|
||||
hostSelector,
|
||||
to: "recovered",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "user_login":
|
||||
return {
|
||||
kind: "internal_event",
|
||||
event: "user_login",
|
||||
hostSelector,
|
||||
cooldownMinutes,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function messageTemplateFor(triggerType: string): string {
|
||||
if (triggerType.endsWith("_threshold")) {
|
||||
return "{{trigger.metric}} on host {{trigger.hostId}} is at {{trigger.value}} (threshold {{trigger.threshold}})";
|
||||
}
|
||||
if (triggerType.startsWith("host_")) {
|
||||
return "Host {{trigger.hostId}} is {{trigger.status}}";
|
||||
}
|
||||
if (triggerType.startsWith("health_check")) {
|
||||
return "Health check {{trigger.checkId}} on host {{trigger.hostId}} is {{trigger.state}}";
|
||||
}
|
||||
return "Triggered on host {{trigger.hostId}}";
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { notificationChannels } from "../../database/db/schema.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { FieldCrypto } from "../field-crypto.js";
|
||||
|
||||
const MIGRATION_FLAG = "notification_channel_config_encrypted_v1";
|
||||
|
||||
export interface ChannelConfigEncryptionResult {
|
||||
encrypted: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification channel configs hold ntfy tokens, webhook auth headers and
|
||||
* Discord webhook URLs, but shipped as plaintext JSON while every other secret
|
||||
* in the app was field-encrypted. This encrypts the rows already on disk.
|
||||
*
|
||||
* Rows whose owner has no usable data key are left alone and retried on a later
|
||||
* boot; the repository reads tolerate both shapes, so a partial run is safe.
|
||||
*/
|
||||
export async function runChannelConfigEncryptionMigration(): Promise<ChannelConfigEncryptionResult | null> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
try {
|
||||
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { drizzle } = createCurrentRepositoryContext();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
userId: notificationChannels.userId,
|
||||
config: notificationChannels.config,
|
||||
})
|
||||
.from(notificationChannels);
|
||||
|
||||
let encrypted = 0;
|
||||
let skipped = 0;
|
||||
let deferred = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.config || FieldCrypto.isEncrypted(row.config)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let userDataKey: Buffer | null = null;
|
||||
try {
|
||||
userDataKey = DataCrypto.getUserDataKey(row.userId);
|
||||
} catch {
|
||||
userDataKey = null;
|
||||
}
|
||||
|
||||
// No usable key yet (legacy DEK pending migration); retry on a later boot.
|
||||
if (!userDataKey) {
|
||||
deferred++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: row.id, config: row.config },
|
||||
row.userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
|
||||
await drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: value })
|
||||
.where(eq(notificationChannels.id, row.id));
|
||||
encrypted++;
|
||||
}
|
||||
|
||||
// Only close the migration out once no row is still waiting on a key, so a
|
||||
// boot that could not reach some users' keys retries those rows next time.
|
||||
if (deferred === 0) {
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
}
|
||||
|
||||
if (encrypted > 0 || deferred > 0) {
|
||||
databaseLogger.info(
|
||||
`Encrypted ${encrypted} notification channel config(s)`,
|
||||
{
|
||||
operation: "channel_config_encryption_migration",
|
||||
encrypted,
|
||||
skipped,
|
||||
deferred,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { encrypted, skipped: skipped + deferred };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Notification channel config encryption failed", {
|
||||
operation: "channel_config_encryption_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async function deriveWebAuthnSystemKey(userId: string): Promise<Buffer> {
|
||||
|
||||
function deriveLegacyDefaultKey(
|
||||
userId: string,
|
||||
type: "oidc" | "webauthn",
|
||||
type: "oidc" | "passkey",
|
||||
): Buffer {
|
||||
const secret =
|
||||
type === "oidc"
|
||||
@@ -144,7 +144,7 @@ function deriveLegacyDefaultKey(
|
||||
async function tryUnwrapSystemWrapped(
|
||||
userId: string,
|
||||
raw: string | null,
|
||||
type: "oidc" | "webauthn",
|
||||
type: "oidc" | "passkey",
|
||||
): Promise<Buffer | null> {
|
||||
const encrypted = parseJson<LegacyEncryptedDEK>(raw);
|
||||
if (!encrypted?.data || !encrypted.iv || !encrypted.tag) return null;
|
||||
@@ -203,7 +203,7 @@ async function unwrapServerSide(userId: string): Promise<Buffer | null> {
|
||||
const fromWebauthn = await tryUnwrapSystemWrapped(
|
||||
userId,
|
||||
getCurrentSettingValue(keys.webauthnWrap),
|
||||
"webauthn",
|
||||
"passkey",
|
||||
);
|
||||
if (fromWebauthn) return fromWebauthn;
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copies the status check options out of ssh_data.stats_config into the
|
||||
* status_check_enabled and status_check_interval columns. Status checks are
|
||||
* core; the rest of stats_config belongs to the host-metrics plugin and is
|
||||
* moved by host-metrics-settings-migration.ts.
|
||||
*
|
||||
* The stats_config column stays in the database, unused: core's drizzle
|
||||
* migrations run before this does, so dropping it there would lose the data.
|
||||
*
|
||||
* Runs once, marked by a settings row, because the new columns have defaults
|
||||
* and there is no other way to tell a copied row from an untouched one.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
export const HOST_STATUS_CONFIG_MIGRATED = "host_status_config_migrated";
|
||||
|
||||
export interface LegacyStatusConfig {
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
}
|
||||
|
||||
/** The status part of an old stats_config value. */
|
||||
export function statusConfigFromStatsConfig(
|
||||
raw: string | null,
|
||||
): LegacyStatusConfig {
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const config =
|
||||
parsed && typeof parsed === "object"
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const seconds = Number(config.statusCheckInterval);
|
||||
return {
|
||||
statusCheckEnabled:
|
||||
config.statusCheckEnabled !== false && config.disableTcpPing !== true,
|
||||
statusCheckInterval:
|
||||
config.useGlobalStatusInterval === false &&
|
||||
Number.isInteger(seconds) &&
|
||||
seconds >= 5
|
||||
? seconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostStatusConfigMigrationResult {
|
||||
skipped: boolean;
|
||||
hostsMoved: number;
|
||||
}
|
||||
|
||||
export async function runHostStatusConfigMigration(): Promise<HostStatusConfigMigrationResult> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
try {
|
||||
if ((await settings.get(HOST_STATUS_CONFIG_MIGRATED)) === "true") {
|
||||
return { skipped: true, hostsMoved: 0 };
|
||||
}
|
||||
} catch {
|
||||
return { skipped: true, hostsMoved: 0 };
|
||||
}
|
||||
|
||||
let hostsMoved = 0;
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares stats_config.
|
||||
const rows = await selectRows<{ id: number; stats_config: string | null }>(
|
||||
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const config = statusConfigFromStatsConfig(row.stats_config);
|
||||
if (config.statusCheckEnabled && config.statusCheckInterval === null) {
|
||||
continue;
|
||||
}
|
||||
// TRUE and FALSE as literals: SQLite cannot bind a JS boolean.
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data
|
||||
SET status_check_enabled = ${sql.raw(config.statusCheckEnabled ? "TRUE" : "FALSE")},
|
||||
status_check_interval = ${config.statusCheckInterval}
|
||||
WHERE id = ${row.id}
|
||||
`);
|
||||
hostsMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
try {
|
||||
await settings.set(HOST_STATUS_CONFIG_MIGRATED, "true");
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not mark the host status migration done", {
|
||||
operation: "host_status_config_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved status check options for ${hostsMoved} host(s) out of stats_config`,
|
||||
{ operation: "host_status_config_migration" },
|
||||
);
|
||||
}
|
||||
return { skipped: false, hostsMoved };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Raw SQL for boot migrations that read columns schema.ts no longer declares.
|
||||
*
|
||||
* getDb() is typed as the SQLite database, but on Postgres and MySQL it is
|
||||
* that engine's drizzle instance, which has execute() instead of all() and
|
||||
* run(). These two pick whichever exists.
|
||||
*/
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
|
||||
type AnyDb = {
|
||||
all?: (query: SQL) => Promise<unknown[]> | unknown[];
|
||||
run?: (query: SQL) => unknown;
|
||||
execute?: (query: SQL) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function selectRows<T>(query: SQL): Promise<T[]> {
|
||||
const db = getDb() as unknown as AnyDb;
|
||||
if (typeof db.all === "function") return (await db.all(query)) as T[];
|
||||
const result = await db.execute!(query);
|
||||
// node-postgres returns { rows }, mysql2 returns [rows, fields].
|
||||
if (Array.isArray(result)) return result[0] as T[];
|
||||
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
|
||||
}
|
||||
|
||||
export async function runStatement(query: SQL): Promise<void> {
|
||||
const db = getDb() as unknown as AnyDb;
|
||||
if (typeof db.run === "function") {
|
||||
await db.run(query);
|
||||
return;
|
||||
}
|
||||
await db.execute!(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy boolean column read raw: SQLite and MySQL hand back 0/1, Postgres
|
||||
* true/false. A column that was never set keeps its old default.
|
||||
*/
|
||||
export function legacyFlag(value: unknown, defaultOn: boolean): boolean {
|
||||
if (value === null || value === undefined) return defaultOn;
|
||||
if (typeof value === "string") return value === "1" || value === "true";
|
||||
return !!value;
|
||||
}
|
||||
|
||||
/**
|
||||
* selectRows for a boot copy out of a column schema.ts no longer declares. A
|
||||
* database created after the column left never had it, so a failed read
|
||||
* means there is nothing to copy rather than an error.
|
||||
*/
|
||||
export async function selectLegacyRows<T>(query: SQL): Promise<T[]> {
|
||||
try {
|
||||
return await selectRows<T>(query);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copies each host's SSH connection options out of ssh_data.terminal_config
|
||||
* into ssh_data.ssh_options: keepalive, legacy algorithms, the agent socket
|
||||
* and identity, agent forwarding and environment variables. The terminal's
|
||||
* look and behavior in the same JSON belong to the ssh-terminal plugin and
|
||||
* are moved by upgrade/ssh-terminal-host-settings-migration.ts.
|
||||
*
|
||||
* terminal_config keeps its values; nothing reads these keys from it any
|
||||
* more. Old Cloudflare Access keys are left behind on purpose.
|
||||
*
|
||||
* Idempotent: only rows whose ssh_options is still null are read, and every
|
||||
* row it reads gets a value, even an empty object.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
import { parseSshOptions } from "../../hosts/ssh-options.js";
|
||||
|
||||
export interface SshOptionsMigrationResult {
|
||||
hostsMoved: number;
|
||||
}
|
||||
|
||||
export async function runSshOptionsMigration(): Promise<SshOptionsMigrationResult> {
|
||||
let hostsMoved = 0;
|
||||
try {
|
||||
const rows = await selectRows<{
|
||||
id: number;
|
||||
terminal_config: string | null;
|
||||
}>(sql`
|
||||
SELECT id, terminal_config FROM ssh_data
|
||||
WHERE ssh_options IS NULL AND terminal_config IS NOT NULL
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const options = parseSshOptions(row.terminal_config);
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data SET ssh_options = ${JSON.stringify(options)}
|
||||
WHERE id = ${row.id} AND ssh_options IS NULL
|
||||
`);
|
||||
if (Object.keys(options).length > 0) hostsMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH options migration failed", {
|
||||
operation: "ssh_options_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { hostsMoved };
|
||||
}
|
||||
|
||||
if (hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved SSH options for ${hostsMoved} host(s) out of terminal_config`,
|
||||
{ operation: "ssh_options_migration" },
|
||||
);
|
||||
}
|
||||
return { hostsMoved };
|
||||
}
|
||||
@@ -180,29 +180,6 @@ class DataCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = store.getUserRecord(userId);
|
||||
if (userRecord) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
store.updateUserSensitiveFields(userId, updatedRecord);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes("users")) {
|
||||
migratedTables.push("users");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { migrated, migratedTables, migratedFieldsCount };
|
||||
} catch (error) {
|
||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||
|
||||
@@ -55,9 +55,7 @@ export function findDatabaseOutsideDataDir(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEmptyDataDirAllowed(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
function isEmptyDataDirAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return TRUE_VALUES.has(
|
||||
env[ALLOW_EMPTY_DATA_DIR_ENV]?.trim().toLowerCase() ?? "",
|
||||
);
|
||||
|
||||
@@ -2,16 +2,33 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER =
|
||||
".database-layer-preupgrade-backup.json";
|
||||
export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX =
|
||||
"pre-database-layer-refactor";
|
||||
/**
|
||||
* Each upgrade that rewrites the database takes its own backup, once. The 2.5
|
||||
* database layer refactor wrote the first marker, so a 2.8 install already has
|
||||
* it; 2.9.0 moves data into plugins and needs a fresh copy regardless.
|
||||
*/
|
||||
export type PreupgradeBackupReason =
|
||||
"pre-database-layer-refactor" | "pre-plugin-runtime";
|
||||
|
||||
export const CURRENT_PREUPGRADE_BACKUP_REASON: PreupgradeBackupReason =
|
||||
"pre-plugin-runtime";
|
||||
|
||||
export function preupgradeBackupMarker(reason: PreupgradeBackupReason): string {
|
||||
return reason === "pre-database-layer-refactor"
|
||||
? ".database-layer-preupgrade-backup.json"
|
||||
: `.preupgrade-backup-${reason}.json`;
|
||||
}
|
||||
|
||||
export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = preupgradeBackupMarker(
|
||||
"pre-database-layer-refactor",
|
||||
);
|
||||
export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV =
|
||||
"DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP";
|
||||
export const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV =
|
||||
const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV =
|
||||
"DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP";
|
||||
|
||||
interface PreupgradeBackupOptions {
|
||||
reason?: PreupgradeBackupReason;
|
||||
dataDir?: string;
|
||||
version?: string;
|
||||
now?: Date;
|
||||
@@ -25,7 +42,7 @@ interface BackupFileEntry {
|
||||
}
|
||||
|
||||
export interface PreupgradeBackupManifest {
|
||||
reason: "pre-database-layer-refactor";
|
||||
reason: PreupgradeBackupReason;
|
||||
createdAt: string;
|
||||
sourceVersion: string;
|
||||
dataDir: string;
|
||||
@@ -71,15 +88,17 @@ function copyIfExists(
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupOldBackups(backupsRoot: string, keepCount: number): void {
|
||||
function cleanupOldBackups(
|
||||
backupsRoot: string,
|
||||
prefix: string,
|
||||
keepCount: number,
|
||||
): void {
|
||||
if (!fs.existsSync(backupsRoot)) return;
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(backupsRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`),
|
||||
(entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}-`),
|
||||
)
|
||||
.map((entry) => {
|
||||
const fullPath = path.join(backupsRoot, entry.name);
|
||||
@@ -95,15 +114,13 @@ function cleanupOldBackups(backupsRoot: string, keepCount: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDatabaseLayerPreupgradeBackup(
|
||||
export function ensurePreupgradeBackup(
|
||||
options: PreupgradeBackupOptions = {},
|
||||
): PreupgradeBackupResult {
|
||||
const reason = options.reason ?? CURRENT_PREUPGRADE_BACKUP_REASON;
|
||||
const env = options.env ?? process.env;
|
||||
const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data");
|
||||
const markerPath = path.join(
|
||||
dataDir,
|
||||
DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER,
|
||||
);
|
||||
const markerPath = path.join(dataDir, preupgradeBackupMarker(reason));
|
||||
|
||||
if (
|
||||
TRUE_VALUES.has(
|
||||
@@ -139,9 +156,7 @@ export function ensureDatabaseLayerPreupgradeBackup(
|
||||
const backupsRoot = path.join(dataDir, "backups");
|
||||
const backupDir = path.join(
|
||||
backupsRoot,
|
||||
`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath(
|
||||
options.now ?? new Date(),
|
||||
)}`,
|
||||
`${reason}-${timestampForPath(options.now ?? new Date())}`,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -152,7 +167,7 @@ export function ensureDatabaseLayerPreupgradeBackup(
|
||||
.filter((entry): entry is BackupFileEntry => entry !== null);
|
||||
|
||||
const manifest: PreupgradeBackupManifest = {
|
||||
reason: "pre-database-layer-refactor",
|
||||
reason,
|
||||
createdAt: (options.now ?? new Date()).toISOString(),
|
||||
sourceVersion: options.version ?? env.VERSION ?? "unknown",
|
||||
dataDir,
|
||||
@@ -166,10 +181,11 @@ export function ensureDatabaseLayerPreupgradeBackup(
|
||||
);
|
||||
fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
cleanupOldBackups(backupsRoot, parseKeepCount(env));
|
||||
cleanupOldBackups(backupsRoot, reason, parseKeepCount(env));
|
||||
|
||||
databaseLogger.info("Database layer pre-upgrade backup created", {
|
||||
operation: "database_layer_preupgrade_backup_created",
|
||||
reason,
|
||||
backupDir,
|
||||
files: files.map((file) => path.basename(file.backup)),
|
||||
});
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
import { statsLogger } from "./logger.js";
|
||||
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||
|
||||
export interface DiscordConfig {
|
||||
url: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
import type { AlertPayload } from "./notification-sender.js";
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
): Promise<void> {
|
||||
const attempt = async () => {
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
const res = await safeOutboundFetch(url, options, allowlist);
|
||||
if (!res.ok) {
|
||||
let body = "";
|
||||
try {
|
||||
body = await res.text();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(
|
||||
`HTTP ${res.status}: ${res.statusText}${body ? ` - ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await attempt();
|
||||
} catch (firstErr) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
await attempt();
|
||||
} catch (secondErr) {
|
||||
statsLogger.warn("Discord notification delivery failed after retry", {
|
||||
operation: "discord_notification_send_failed",
|
||||
url,
|
||||
error:
|
||||
secondErr instanceof Error ? secondErr.message : String(secondErr),
|
||||
});
|
||||
throw secondErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDiscord(
|
||||
config: DiscordConfig,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
const { url, username, avatar_url } = config;
|
||||
const colorMap: Record<string, number> = {
|
||||
info: 3066993,
|
||||
warning: 16753920,
|
||||
critical: 15158332,
|
||||
};
|
||||
const color = colorMap[payload.severity] ?? 3447003;
|
||||
|
||||
const embed: Record<string, unknown> = {
|
||||
title: `[Termix] ${payload.hostName}: ${payload.ruleName}`,
|
||||
description: payload.message,
|
||||
color,
|
||||
fields: [
|
||||
{ name: "Host", value: payload.hostName || "—", inline: true },
|
||||
{ name: "Rule", value: payload.ruleName || "—", inline: true },
|
||||
{ name: "Severity", value: payload.severity, inline: true },
|
||||
],
|
||||
timestamp: payload.timestamp,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
if (payload.value !== undefined && payload.value !== null) {
|
||||
(embed.fields as any).push({
|
||||
name: "Value",
|
||||
value: String(payload.value),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { embeds: [embed] };
|
||||
if (username) (body as any).username = username;
|
||||
if (avatar_url) (body as any).avatar_url = avatar_url;
|
||||
|
||||
await fetchWithRetry(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Request/response layer over the fork IPC channel between the embedded
|
||||
* backend and Electron's main process (electron/main.cjs forks this backend
|
||||
* with stdio: [..., "ipc"]). Main already sends a one-way "shutdown" message
|
||||
* on this channel; this adds a matching request/response shape so the
|
||||
* backend can ask main to do something only main can do - open a real
|
||||
* BrowserWindow - and get a typed result back.
|
||||
*
|
||||
* Not available outside Electron: process.send is undefined for a plain
|
||||
* `node` or Docker start, and ELECTRON_EMBEDDED distinguishes the embedded
|
||||
* backend from a standalone one that happens to run under a process manager
|
||||
* that also sets stdio to "ipc".
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface ElectronIpcRequestMessage {
|
||||
type: "backend-request";
|
||||
id: string;
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ElectronIpcResponseMessage {
|
||||
type: "backend-response";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isResponseMessage(msg: unknown): msg is ElectronIpcResponseMessage {
|
||||
return (
|
||||
!!msg &&
|
||||
typeof msg === "object" &&
|
||||
(msg as { type?: unknown }).type === "backend-response"
|
||||
);
|
||||
}
|
||||
|
||||
export function isElectronIpcAvailable(): boolean {
|
||||
return (
|
||||
process.env.ELECTRON_EMBEDDED === "true" &&
|
||||
typeof process.send === "function"
|
||||
);
|
||||
}
|
||||
|
||||
const pending = new Map<
|
||||
string,
|
||||
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
let listening = false;
|
||||
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
listening = true;
|
||||
process.on("message", (msg: unknown) => {
|
||||
if (!isResponseMessage(msg)) return;
|
||||
const waiter = pending.get(msg.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(msg.id);
|
||||
if (msg.ok) waiter.resolve(msg.result);
|
||||
else waiter.reject(new Error(msg.error || "Electron IPC request failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to Electron's main process over the fork IPC channel and
|
||||
* waits for its response. Rejects if main never answers (main.cjs is where
|
||||
* a killed or hung renderer would surface, not here) or process.send is
|
||||
* unavailable.
|
||||
*/
|
||||
export async function requestFromElectronMain<T = unknown>(
|
||||
channel: string,
|
||||
payload: unknown,
|
||||
): Promise<T> {
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error("Electron IPC is only available in the desktop app");
|
||||
}
|
||||
ensureListening();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const message: ElectronIpcRequestMessage = {
|
||||
type: "backend-request",
|
||||
id,
|
||||
channel,
|
||||
payload,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Electron IPC request "${channel}" timed out`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
process.send?.(message);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
systemLogger.warn("Failed to send Electron IPC request", {
|
||||
operation: "electron_ipc_send_failed",
|
||||
channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const CALLBACK_PATH = "/oidc-callback";
|
||||
|
||||
/**
|
||||
* Whether a redirect login's return address is the desktop app's loopback
|
||||
* listener or the mobile app's scheme, which get the token in the URL
|
||||
* because they cannot read a cookie.
|
||||
*/
|
||||
export function isExternalTokenCallback(value: string): boolean {
|
||||
if (value.startsWith("termix-mobile:")) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "http:" &&
|
||||
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
|
||||
url.pathname === CALLBACK_PATH
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,25 +15,13 @@ class FieldCrypto {
|
||||
private static readonly SALT_LENGTH = 32;
|
||||
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set([
|
||||
"passwordHash",
|
||||
"clientSecret",
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
"oidcIdentifier",
|
||||
]),
|
||||
users: new Set(["passwordHash", "clientSecret", "oidcIdentifier"]),
|
||||
ssh_data: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
]),
|
||||
ssh_credentials: new Set([
|
||||
"password",
|
||||
@@ -44,12 +32,6 @@ class FieldCrypto {
|
||||
]),
|
||||
// Channel configs hold ntfy tokens, webhook auth headers and Discord
|
||||
// webhook URLs, which are credentials like any other.
|
||||
notification_channels: new Set(["config"]),
|
||||
opkssh_tokens: new Set(["sshCert", "privateKey"]),
|
||||
termix_identity_ca: new Set(["privateKey"]),
|
||||
vault_tokens: new Set(["sshCert", "privateKey"]),
|
||||
// Third-party AI provider keys are user credentials like any other.
|
||||
ai_providers: new Set(["apiKey"]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
@@ -142,6 +124,13 @@ class FieldCrypto {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/** The encrypted fields of a table, as property names. */
|
||||
static fieldsFor(tableName: string): string[] {
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return fields ? [...fields] : [];
|
||||
}
|
||||
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
createCurrentRbacPermissionRepository,
|
||||
createCurrentRoleRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import {
|
||||
loadKnownPermissions,
|
||||
rememberPermissionGroup,
|
||||
isValidPermission,
|
||||
} from "./permission-catalog.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
function isConcretePermission(permission: string): boolean {
|
||||
return permission !== "*" && !permission.endsWith(".*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every permission core has ever registered, and backfills the record
|
||||
* from what roles already hold.
|
||||
*
|
||||
* The backfill is what makes the upgrade lossless. Before A5 a plugin
|
||||
* permission existed only in memory while its plugin was active, so an install
|
||||
* upgrading with a plugin disabled would have had `<plugin>.use` rejected on the
|
||||
* next role save. Seeding from the roles themselves means anything an admin was
|
||||
* already relying on stays valid, whether or not the plugin ever loads again.
|
||||
*/
|
||||
export async function primeKnownPermissions(): Promise<void> {
|
||||
try {
|
||||
const rbacRepository = createCurrentRbacPermissionRepository();
|
||||
|
||||
const stored = await rbacRepository.listKnown();
|
||||
for (const row of stored) {
|
||||
if (isConcretePermission(row.permission)) {
|
||||
loadKnownPermissions([row.permission]);
|
||||
} else {
|
||||
// A stored "<group>.*" row records a group nothing concrete named.
|
||||
rememberPermissionGroup(row.permission.slice(0, -2));
|
||||
}
|
||||
}
|
||||
|
||||
const fromRoles = new Set<string>();
|
||||
for (const role of await createCurrentRoleRepository().listRoles()) {
|
||||
if (!role.permissions) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(role.permissions);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const permission of parsed) {
|
||||
if (typeof permission !== "string") continue;
|
||||
// Already known, from the static catalog or a previous boot.
|
||||
if (isValidPermission(permission)) continue;
|
||||
|
||||
// A role holding only "ai.*" still has to keep saving, so the group is
|
||||
// remembered even though the wildcard itself is not a stored id.
|
||||
if (!isConcretePermission(permission)) {
|
||||
rememberPermissionGroup(permission.slice(0, -2));
|
||||
}
|
||||
// Wildcards are stored too, so the group survives the next restart.
|
||||
fromRoles.add(permission);
|
||||
}
|
||||
}
|
||||
|
||||
if (fromRoles.size === 0) return;
|
||||
|
||||
loadKnownPermissions(
|
||||
[...fromRoles].filter((permission) => isConcretePermission(permission)),
|
||||
);
|
||||
await rbacRepository.recordKnown(
|
||||
[...fromRoles].map((permission) => ({ permission })),
|
||||
);
|
||||
|
||||
databaseLogger.info(
|
||||
`Recorded ${fromRoles.size} permission(s) already held by a role`,
|
||||
{ operation: "prime_known_permissions" },
|
||||
);
|
||||
} catch (error) {
|
||||
// A failure here only means a plugin permission may be rejected on a role
|
||||
// save until its plugin registers again. It must never stop the boot.
|
||||
databaseLogger.warn("Could not load known permissions", {
|
||||
operation: "prime_known_permissions",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { getProxyAgent } from "./proxy-agent.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubAPIResponse,
|
||||
GitHubRelease,
|
||||
} from "../../types/index.js";
|
||||
|
||||
class GitHubCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 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 githubCache = new GitHubCache();
|
||||
|
||||
function parseSemver(
|
||||
version: string | undefined,
|
||||
): [number, number, number] | null {
|
||||
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
||||
}
|
||||
|
||||
export function compareSemver(
|
||||
a: string | undefined,
|
||||
b: string | undefined,
|
||||
): number | null {
|
||||
const parsedA = parseSemver(a);
|
||||
const parsedB = parseSemver(b);
|
||||
if (!parsedA || !parsedB) return null;
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (parsedA[i] > parsedB[i]) return 1;
|
||||
if (parsedA[i] < parsedB[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
export const REPO_OWNER = "Termix-SSH";
|
||||
export const REPO_NAME = "Termix";
|
||||
|
||||
export async function fetchGitHubAPI<T>(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): Promise<GitHubAPIResponse<T>> {
|
||||
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
|
||||
if (cachedEntry) {
|
||||
return {
|
||||
data: cachedEntry.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedEntry.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${GITHUB_API_BASE}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
const cacheData: CacheEntry<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
};
|
||||
githubCache.set(cacheKey, cacheData);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||
operation: "github_api",
|
||||
endpoint,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string;
|
||||
tagName: string;
|
||||
name: string;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/** The newest Termix release on GitHub, cached for 30 minutes. */
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
|
||||
const release = await fetchGitHubAPI<GitHubRelease>(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
"latest_release",
|
||||
);
|
||||
const rawTag = release.data.tag_name || release.data.name || "";
|
||||
const version = rawTag.match(/(\d+\.\d+(\.\d+)?)/)?.[1];
|
||||
if (!version) return null;
|
||||
return {
|
||||
version,
|
||||
tagName: release.data.tag_name,
|
||||
name: release.data.name,
|
||||
url: release.data.html_url,
|
||||
publishedAt: release.data.published_at,
|
||||
};
|
||||
}
|
||||
@@ -7,13 +7,7 @@ export class LazyFieldEncryption {
|
||||
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
|
||||
key_password: "keyPassword",
|
||||
sudo_password: "sudoPassword",
|
||||
autostart_password: "autostartPassword",
|
||||
autostart_key: "autostartKey",
|
||||
autostart_key_password: "autostartKeyPassword",
|
||||
socks5_password: "socks5Password",
|
||||
rdp_password: "rdpPassword",
|
||||
vnc_password: "vncPassword",
|
||||
telnet_password: "telnetPassword",
|
||||
private_key: "privateKey",
|
||||
public_key: "publicKey",
|
||||
password_hash: "passwordHash",
|
||||
@@ -24,13 +18,7 @@ export class LazyFieldEncryption {
|
||||
|
||||
keyPassword: "key_password",
|
||||
sudoPassword: "sudo_password",
|
||||
autostartPassword: "autostart_password",
|
||||
autostartKey: "autostart_key",
|
||||
autostartKeyPassword: "autostart_key_password",
|
||||
socks5Password: "socks5_password",
|
||||
rdpPassword: "rdp_password",
|
||||
vncPassword: "vnc_password",
|
||||
telnetPassword: "telnet_password",
|
||||
privateKey: "private_key",
|
||||
publicKey: "public_key",
|
||||
passwordHash: "password_hash",
|
||||
@@ -97,27 +85,6 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
// Guac hosts migrated from single-protocol: rdpPassword/vncPassword/telnetPassword
|
||||
// columns were populated by copying the encrypted `password` blob. Try decrypting
|
||||
// under the original field name before giving up.
|
||||
if (
|
||||
fieldName === "rdpPassword" ||
|
||||
fieldName === "vncPassword" ||
|
||||
fieldName === "telnetPassword"
|
||||
) {
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
"password",
|
||||
);
|
||||
return decrypted;
|
||||
} catch {
|
||||
// not encrypted as "password" either
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
@@ -125,13 +92,7 @@ export class LazyFieldEncryption {
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"clientSecret",
|
||||
@@ -279,15 +240,7 @@ export class LazyFieldEncryption {
|
||||
privateKey: "private_key",
|
||||
publicKey: "public_key",
|
||||
sudoPassword: "sudo_password",
|
||||
autostartPassword: "autostart_password",
|
||||
autostartKey: "autostart_key",
|
||||
autostartKeyPassword: "autostart_key_password",
|
||||
socks5Password: "socks5_password",
|
||||
rdpPassword: "rdp_password",
|
||||
vncPassword: "vnc_password",
|
||||
telnetPassword: "telnet_password",
|
||||
totpSecret: "totp_secret",
|
||||
totpBackupCodes: "totp_backup_codes",
|
||||
clientSecret: "client_secret",
|
||||
oidcIdentifier: "oidc_identifier",
|
||||
};
|
||||
@@ -299,13 +252,7 @@ export class LazyFieldEncryption {
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
],
|
||||
ssh_credentials: [
|
||||
"password",
|
||||
@@ -314,7 +261,6 @@ export class LazyFieldEncryption {
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
],
|
||||
users: ["totpSecret", "totpBackupCodes"],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
@@ -439,33 +385,6 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
const user = store.getUserRecord(userId);
|
||||
if (user) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("users");
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
const column = this.propertyToColumn(field);
|
||||
const value = user[column];
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value &&
|
||||
this.fieldNeedsMigration(value, userKEK, userId, field)
|
||||
) {
|
||||
userPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (userPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: "users",
|
||||
recordId: userId,
|
||||
fields: userPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { needsMigration, plaintextFields };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check user migration needs", error, {
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface LogContext {
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
@@ -51,9 +50,6 @@ const SENSITIVE_FIELDS = [
|
||||
"secret",
|
||||
"clientSecret",
|
||||
"keyPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"credentialId",
|
||||
"authToken",
|
||||
"jwt",
|
||||
@@ -142,8 +138,6 @@ export class Logger {
|
||||
contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId)
|
||||
contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName)
|
||||
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId)
|
||||
contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId)
|
||||
@@ -258,10 +252,6 @@ export class Logger {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
@@ -299,15 +289,13 @@ export class Logger {
|
||||
|
||||
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316");
|
||||
export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16");
|
||||
export const syncLogger = new Logger("SYNC", "🔄", "#06b6d4");
|
||||
|
||||
export const logger = systemLogger;
|
||||
@@ -1,106 +0,0 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { authLogger } from "./logger.js";
|
||||
|
||||
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
|
||||
const NGINX_CONF = "/tmp/nginx/nginx.conf";
|
||||
const NGINX_PID = "/tmp/nginx/nginx.pid";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||
const ENV_FILE = path.join(DATA_DIR, ".env");
|
||||
|
||||
function persistSSLEnv(sslPort: string, certPath: string, keyPath: string) {
|
||||
const vars: Record<string, string> = {
|
||||
ENABLE_SSL: "true",
|
||||
SSL_PORT: sslPort,
|
||||
SSL_CERT_PATH: certPath,
|
||||
SSL_KEY_PATH: keyPath,
|
||||
};
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = readFileSync(ENV_FILE, "utf8");
|
||||
} catch {
|
||||
// no existing .env file yet
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, "m");
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
if (!content.includes("# SSL Configuration")) {
|
||||
content += `\n# SSL Configuration (Auto-generated)\n`;
|
||||
}
|
||||
content += `${key}=${value}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE, content.trim() + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the nginx config from the HTTPS template and reloads nginx so a
|
||||
* newly issued/uploaded certificate is served without a full container
|
||||
* restart. No-ops outside the Docker image (template/binary won't exist).
|
||||
*/
|
||||
export function reloadNginxWithSSL(): { applied: boolean; message: string } {
|
||||
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) {
|
||||
return {
|
||||
applied: false,
|
||||
message:
|
||||
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.",
|
||||
};
|
||||
}
|
||||
|
||||
const port = process.env.PORT || "8080";
|
||||
const sslPort = process.env.SSL_PORT || "8443";
|
||||
const certPath =
|
||||
process.env.SSL_CERT_PATH || path.join(SSL_DIR, "termix.crt");
|
||||
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
|
||||
|
||||
try {
|
||||
const template = readFileSync(NGINX_TEMPLATE, "utf8");
|
||||
const rendered = template
|
||||
.split("${PORT}")
|
||||
.join(port)
|
||||
.split("${SSL_PORT}")
|
||||
.join(sslPort)
|
||||
.split("${SSL_CERT_PATH}")
|
||||
.join(certPath)
|
||||
.split("${SSL_KEY_PATH}")
|
||||
.join(keyPath);
|
||||
|
||||
writeFileSync(NGINX_CONF, rendered);
|
||||
|
||||
execFileSync("nginx", ["-t", "-c", NGINX_CONF], { stdio: "pipe" });
|
||||
execFileSync("nginx", ["-s", "reload", "-c", NGINX_CONF], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
process.env.ENABLE_SSL = "true";
|
||||
process.env.SSL_PORT = sslPort;
|
||||
process.env.SSL_CERT_PATH = certPath;
|
||||
process.env.SSL_KEY_PATH = keyPath;
|
||||
persistSSLEnv(sslPort, certPath, keyPath);
|
||||
|
||||
authLogger.info("nginx reloaded with HTTPS enabled", {
|
||||
operation: "nginx_ssl_reload",
|
||||
sslPort,
|
||||
});
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
message: `HTTPS is now active on port ${sslPort}. Make sure that port is published/mapped to this container.`,
|
||||
};
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to reload nginx with SSL config", err);
|
||||
return {
|
||||
applied: false,
|
||||
message:
|
||||
"Certificate installed, but nginx could not be reloaded automatically. Restart Termix with ENABLE_SSL=true to apply it.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export const NOTIFICATION_PRIVATE_ALLOWLIST_KEY =
|
||||
"notification_private_endpoint_allowlist";
|
||||
|
||||
@@ -16,10 +14,3 @@ export function parseNotificationAllowlist(raw: string | null): string[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNotificationPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseNotificationAllowlist(raw);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { statsLogger } from "./logger.js";
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||
|
||||
export interface AlertPayload {
|
||||
hostName: string;
|
||||
hostId: number;
|
||||
triggerType: string;
|
||||
value?: number;
|
||||
threshold?: number;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
timestamp: string;
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
}
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: "POST" | "PUT";
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const NTFY_PRIORITY: Record<string, number> = {
|
||||
info: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
): Promise<void> {
|
||||
const attempt = async () => {
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
const res = await safeOutboundFetch(url, options, allowlist);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await attempt();
|
||||
} catch (firstErr) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
await attempt();
|
||||
} catch (secondErr) {
|
||||
statsLogger.warn("Notification delivery failed after retry", {
|
||||
operation: "notification_send_failed",
|
||||
url,
|
||||
error:
|
||||
secondErr instanceof Error ? secondErr.message : String(secondErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
config: WebhookConfig,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
const { url, headers = {}, method = "POST" } = config;
|
||||
await fetchWithRetry(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNtfy(
|
||||
config: NtfyConfig,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
const { url, topic, token } = config;
|
||||
const ntfyUrl = `${url.replace(/\/$/, "")}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Title: `[Termix] ${payload.hostName}: ${payload.ruleName}`,
|
||||
Priority: String(NTFY_PRIORITY[payload.severity] ?? 3),
|
||||
Tags:
|
||||
payload.severity === "critical"
|
||||
? "rotating_light"
|
||||
: payload.severity === "warning"
|
||||
? "warning"
|
||||
: "information_source",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
await fetchWithRetry(ntfyUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: payload.message,
|
||||
});
|
||||
}
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function sendNotification(
|
||||
channel: NotificationChannel,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
if (!channel.enabled) return;
|
||||
|
||||
let parsedConfig: Record<string, unknown>;
|
||||
try {
|
||||
parsedConfig = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
statsLogger.warn("Failed to parse notification channel config", {
|
||||
operation: "notification_config_parse_error",
|
||||
channelId: channel.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (channel.type === "webhook") {
|
||||
await sendWebhook(parsedConfig as unknown as WebhookConfig, payload);
|
||||
} else if (channel.type === "ntfy") {
|
||||
await sendNtfy(parsedConfig as unknown as NtfyConfig, payload);
|
||||
}
|
||||
} catch (err) {
|
||||
statsLogger.warn("Notification send error", {
|
||||
operation: "notification_send_error",
|
||||
channelId: channel.id,
|
||||
type: channel.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
const CALLBACK_PATH = "/oidc-callback";
|
||||
|
||||
export function getDesktopOidcCallbackUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
|
||||
return `http://localhost:${port}${CALLBACK_PATH}`;
|
||||
}
|
||||
|
||||
export function isOidcTokenCallback(value: string): boolean {
|
||||
if (value.startsWith("termix-mobile:")) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "http:" &&
|
||||
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
|
||||
url.pathname === CALLBACK_PATH
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
|
||||
/**
|
||||
* The slice of the 1Password Connect REST API needed to resolve a secret
|
||||
* reference: find the vault, find the item, read one field.
|
||||
* https://developer.1password.com/docs/connect/api-reference
|
||||
*/
|
||||
|
||||
export interface SecretReference {
|
||||
vault: string;
|
||||
item: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** op://<vault>/<item>/<field> (a trailing ?query, as in ?ssh-format=openssh, is ignored) */
|
||||
export function parseSecretReference(raw: string): SecretReference | null {
|
||||
const match = /^op:\/\/([^/]+)\/([^/]+)\/([^/?]+)(?:\?.*)?$/.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const [, vault, item, field] = match.map((part) => decodeURIComponent(part));
|
||||
return { vault, item, field };
|
||||
}
|
||||
|
||||
export function isSecretReference(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trimStart().startsWith("op://");
|
||||
}
|
||||
|
||||
export interface ConnectSource {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
allowedPrivateHosts: readonly string[];
|
||||
}
|
||||
|
||||
async function connectGet<T>(source: ConnectSource, path: string): Promise<T> {
|
||||
const base = source.baseUrl.replace(/\/+$/, "");
|
||||
const response = await safeOutboundFetch(
|
||||
`${base}${path}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${source.token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
},
|
||||
source.allowedPrivateHosts,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`1Password Connect ${path} failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function eqFilter(value: string): string {
|
||||
return encodeURIComponent(`title eq "${value.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
interface ConnectVault {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface ConnectItemSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
interface ConnectField {
|
||||
id: string;
|
||||
label?: string;
|
||||
purpose?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** Reachability + token validity: the vault list needs a valid token. */
|
||||
export async function testConnectSource(
|
||||
source: ConnectSource,
|
||||
): Promise<number> {
|
||||
const vaults = await connectGet<ConnectVault[]>(source, "/v1/vaults");
|
||||
return vaults.length;
|
||||
}
|
||||
|
||||
const byIdOrName =
|
||||
(name: string) =>
|
||||
(candidate: { id: string; name?: string; title?: string }) =>
|
||||
candidate.id === name ||
|
||||
(candidate.name ?? candidate.title ?? "").toLowerCase() ===
|
||||
name.toLowerCase();
|
||||
|
||||
export async function resolveConnectReference(
|
||||
source: ConnectSource,
|
||||
ref: SecretReference,
|
||||
): Promise<string> {
|
||||
const vaults = await connectGet<ConnectVault[]>(
|
||||
source,
|
||||
`/v1/vaults?filter=${eqFilter(ref.vault)}`,
|
||||
);
|
||||
const vault =
|
||||
vaults.find(byIdOrName(ref.vault)) ??
|
||||
(await connectGet<ConnectVault[]>(source, "/v1/vaults")).find(
|
||||
byIdOrName(ref.vault),
|
||||
);
|
||||
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
|
||||
|
||||
const items = await connectGet<ConnectItemSummary[]>(
|
||||
source,
|
||||
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
|
||||
);
|
||||
const summary =
|
||||
items.find(byIdOrName(ref.item)) ??
|
||||
(ref.item.length >= 26 ? { id: ref.item, title: ref.item } : undefined);
|
||||
if (!summary) {
|
||||
throw new Error(`1Password item "${ref.item}" not found in "${ref.vault}"`);
|
||||
}
|
||||
|
||||
const item = await connectGet<{ fields?: ConnectField[] }>(
|
||||
source,
|
||||
`/v1/vaults/${vault.id}/items/${summary.id}`,
|
||||
);
|
||||
const wanted = ref.field.toLowerCase();
|
||||
const field = (item.fields ?? []).find(
|
||||
(f) =>
|
||||
f.id === ref.field ||
|
||||
f.label?.toLowerCase() === wanted ||
|
||||
f.purpose?.toLowerCase() === wanted ||
|
||||
(wanted === "private key" && f.id === "private_key") ||
|
||||
(wanted === "private_key" && f.label?.toLowerCase() === "private key"),
|
||||
);
|
||||
if (!field || field.value === undefined) {
|
||||
throw new Error(
|
||||
`1Password field "${ref.field}" not found on "${ref.item}"`,
|
||||
);
|
||||
}
|
||||
return field.value;
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { createWriteStream, promises as fs } from "fs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
const OPKSSH_REPO = "openpubkey/opkssh";
|
||||
const DEFAULT_OPKSSH_VERSION = "v0.16.0";
|
||||
const DEFAULT_CHECKSUMS: Record<string, string> = {
|
||||
"opkssh-linux-amd64":
|
||||
"c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf",
|
||||
"opkssh-linux-arm64":
|
||||
"9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44",
|
||||
"opkssh-osx-amd64":
|
||||
"e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146",
|
||||
"opkssh-osx-arm64":
|
||||
"be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd",
|
||||
"opkssh-windows-amd64.exe":
|
||||
"db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07",
|
||||
"opkssh-windows-arm64.exe":
|
||||
"c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af",
|
||||
};
|
||||
|
||||
function getBinaryDir(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, "opkssh");
|
||||
}
|
||||
|
||||
function getVersionFile(): string {
|
||||
return path.join(getBinaryDir(), "version.txt");
|
||||
}
|
||||
|
||||
function getChecksumFile(): string {
|
||||
return path.join(getBinaryDir(), "checksum.txt");
|
||||
}
|
||||
|
||||
function getTrustedRelease(binaryName: string): {
|
||||
version: string;
|
||||
checksum: string;
|
||||
} {
|
||||
const version = process.env.OPKSSH_VERSION || DEFAULT_OPKSSH_VERSION;
|
||||
const configuredChecksum = process.env.OPKSSH_SHA256?.trim().toLowerCase();
|
||||
const checksum =
|
||||
version === DEFAULT_OPKSSH_VERSION
|
||||
? DEFAULT_CHECKSUMS[binaryName]
|
||||
: configuredChecksum;
|
||||
if (!checksum || !/^[0-9a-f]{64}$/.test(checksum)) {
|
||||
throw new Error(
|
||||
`OPKSSH ${version} has no trusted SHA-256 checksum for ${binaryName}`,
|
||||
);
|
||||
}
|
||||
return { version, checksum };
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
const contents = await fs.readFile(filePath);
|
||||
return crypto.createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
async function verifyChecksum(
|
||||
filePath: string,
|
||||
expectedChecksum: string,
|
||||
): Promise<void> {
|
||||
const actualChecksum = await sha256File(filePath);
|
||||
if (actualChecksum !== expectedChecksum) {
|
||||
throw new Error(`OPKSSH checksum mismatch for ${path.basename(filePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceBinary(
|
||||
temporaryPath: string,
|
||||
binaryPath: string,
|
||||
): Promise<void> {
|
||||
const backupPath = `${binaryPath}.previous`;
|
||||
let backedUp = false;
|
||||
try {
|
||||
await fs.rename(binaryPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(temporaryPath, binaryPath);
|
||||
if (backedUp) await fs.rm(backupPath, { force: true });
|
||||
} catch (error) {
|
||||
if (backedUp) await fs.rename(backupPath, binaryPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledDir(): string {
|
||||
return (
|
||||
process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled")
|
||||
);
|
||||
}
|
||||
|
||||
interface GitHubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
assets: GitHubAsset[];
|
||||
}
|
||||
|
||||
export class OPKSSHBinaryManager {
|
||||
private static binaryPath: string | null = null;
|
||||
|
||||
static async ensureBinary(): Promise<string> {
|
||||
if (this.binaryPath) {
|
||||
return this.binaryPath;
|
||||
}
|
||||
|
||||
const binaryName = this.getBinaryName();
|
||||
const expectedPath = path.join(getBinaryDir(), binaryName);
|
||||
|
||||
try {
|
||||
await fs.access(expectedPath);
|
||||
const storedChecksum = (await fs.readFile(getChecksumFile(), "utf8"))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
await verifyChecksum(expectedPath, storedChecksum);
|
||||
const needsUpdate = await this.checkForUpdate();
|
||||
if (needsUpdate) {
|
||||
systemLogger.info("Newer OPKSSH version available, updating...", {
|
||||
operation: "opkssh_binary_update_start",
|
||||
});
|
||||
await this.downloadBinary();
|
||||
}
|
||||
|
||||
this.binaryPath = expectedPath;
|
||||
return expectedPath;
|
||||
} catch {
|
||||
const usedBundled = await this.useBundledBinary(expectedPath);
|
||||
if (usedBundled) {
|
||||
this.binaryPath = expectedPath;
|
||||
return expectedPath;
|
||||
}
|
||||
|
||||
systemLogger.info("OPKSSH binary not found, downloading...", {
|
||||
operation: "opkssh_binary_download_start",
|
||||
});
|
||||
await this.downloadBinary();
|
||||
this.binaryPath = expectedPath;
|
||||
return expectedPath;
|
||||
}
|
||||
}
|
||||
|
||||
private static async useBundledBinary(
|
||||
expectedPath: string,
|
||||
): Promise<boolean> {
|
||||
const binaryName = this.getBinaryName();
|
||||
const bundledPath = path.join(getBundledDir(), binaryName);
|
||||
const { version, checksum } = getTrustedRelease(binaryName);
|
||||
|
||||
try {
|
||||
await fs.access(bundledPath);
|
||||
await verifyChecksum(bundledPath, checksum);
|
||||
await fs.mkdir(getBinaryDir(), { recursive: true });
|
||||
await fs.copyFile(bundledPath, expectedPath);
|
||||
await fs.chmod(expectedPath, 0o755);
|
||||
await fs.writeFile(getChecksumFile(), checksum, "utf8");
|
||||
|
||||
await fs.writeFile(getVersionFile(), version, "utf8");
|
||||
|
||||
systemLogger.info("Using bundled OPKSSH binary", {
|
||||
operation: "opkssh_binary_bundled_used",
|
||||
path: expectedPath,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async downloadBinary(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(getBinaryDir(), { recursive: true });
|
||||
|
||||
const release = await this.getLatestRelease();
|
||||
|
||||
const asset = this.findMatchingAsset(release.assets);
|
||||
if (!asset) {
|
||||
throw new Error(
|
||||
`No matching OPKSSH binary found for platform ${process.platform} ${process.arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
const binaryName = this.getBinaryName();
|
||||
const binaryPath = path.join(getBinaryDir(), binaryName);
|
||||
const temporaryPath = `${binaryPath}.download-${crypto.randomUUID()}`;
|
||||
const { checksum } = getTrustedRelease(binaryName);
|
||||
|
||||
const response = await fetch(asset.browser_download_url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = createWriteStream(temporaryPath, { mode: 0o600 });
|
||||
await pipeline(
|
||||
response.body as unknown as NodeJS.ReadableStream,
|
||||
fileStream,
|
||||
);
|
||||
await verifyChecksum(temporaryPath, checksum);
|
||||
await fs.chmod(temporaryPath, 0o755);
|
||||
await replaceBinary(temporaryPath, binaryPath);
|
||||
} catch (error) {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
await fs.writeFile(getVersionFile(), release.tag_name, "utf8");
|
||||
await fs.writeFile(getChecksumFile(), checksum, "utf8");
|
||||
|
||||
systemLogger.info(
|
||||
`OPKSSH binary downloaded successfully to ${binaryPath}`,
|
||||
{
|
||||
operation: "opkssh_binary_download_complete",
|
||||
path: binaryPath,
|
||||
version: release.tag_name,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to download OPKSSH binary", error, {
|
||||
operation: "opkssh_binary_download_error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static getBinaryPath(): string {
|
||||
if (!this.binaryPath) {
|
||||
throw new Error(
|
||||
"OPKSSH binary not initialized. Call ensureBinary() first.",
|
||||
);
|
||||
}
|
||||
return this.binaryPath;
|
||||
}
|
||||
|
||||
private static async checkForUpdate(): Promise<boolean> {
|
||||
try {
|
||||
let localVersion: string | null = null;
|
||||
try {
|
||||
localVersion = await fs.readFile(getVersionFile(), "utf8");
|
||||
localVersion = localVersion.trim();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const latestVersion = getTrustedRelease(this.getBinaryName()).version;
|
||||
|
||||
if (localVersion !== latestVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
systemLogger.warn("Failed to check for OPKSSH updates", {
|
||||
operation: "opkssh_update_check_failed",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async getLatestRelease(): Promise<GitHubRelease> {
|
||||
const { version } = getTrustedRelease(this.getBinaryName());
|
||||
const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/tags/${encodeURIComponent(version)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch release info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as GitHubRelease;
|
||||
}
|
||||
|
||||
private static findMatchingAsset(assets: GitHubAsset[]): GitHubAsset | null {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
const mappedOs = osMap[platform];
|
||||
const mappedArch = archMap[arch];
|
||||
|
||||
if (!mappedOs || !mappedArch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
`opkssh-${mappedOs}-${mappedArch}.exe`,
|
||||
`opkssh-${mappedOs}-${mappedArch}`,
|
||||
`opkssh_${mappedOs}_${mappedArch}.exe`,
|
||||
`opkssh_${mappedOs}_${mappedArch}`,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const asset = assets.find(
|
||||
(a) => a.name.toLowerCase() === pattern.toLowerCase(),
|
||||
);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static getBinaryName(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
const mappedOs = osMap[platform] || platform;
|
||||
const mappedArch = archMap[arch] || arch;
|
||||
|
||||
const extension = platform === "win32" ? ".exe" : "";
|
||||
return `opkssh-${mappedOs}-${mappedArch}${extension}`;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,31 @@
|
||||
// Single source of truth for role permission strings. The admin role editor
|
||||
// renders this catalog and PUT /rbac/roles/:id validates against it;
|
||||
// PermissionManager.hasPermission resolves wildcards ("*", "<group>.*").
|
||||
export interface PermissionCatalogItem {
|
||||
permission: string;
|
||||
/** Plugin-relative i18n keys; the frontend resolves them per plugin. */
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
export interface PermissionCatalogEntry {
|
||||
group: string;
|
||||
permissions: string[];
|
||||
/** Set for a group a plugin contributed. */
|
||||
pluginId?: string;
|
||||
/** Core groups carry an i18n key, plugin groups their display name. */
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
/** False once the owning plugin is disabled or gone. */
|
||||
enabled?: boolean;
|
||||
items?: PermissionCatalogItem[];
|
||||
}
|
||||
|
||||
export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
{
|
||||
group: "hosts",
|
||||
labelKey: "admin.rolePermissions.groups.hosts",
|
||||
permissions: [
|
||||
"hosts.view",
|
||||
"hosts.create",
|
||||
@@ -17,28 +34,9 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"hosts.share",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "snippets",
|
||||
permissions: [
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
"snippets.edit",
|
||||
"snippets.delete",
|
||||
"snippets.share",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "automations",
|
||||
permissions: [
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "credentials",
|
||||
labelKey: "admin.rolePermissions.groups.credentials",
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
@@ -47,34 +45,50 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"credentials.share",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "ai",
|
||||
permissions: ["ai.use", "ai.manage_providers", "ai.apply_proposals"],
|
||||
},
|
||||
{
|
||||
group: "admin",
|
||||
labelKey: "admin.rolePermissions.groups.admin",
|
||||
permissions: [
|
||||
"admin.users.view",
|
||||
"admin.users.manage",
|
||||
"admin.roles.manage",
|
||||
"admin.settings.manage",
|
||||
"admin.sessions.manage",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Groups registered at runtime, e.g. by plugins. Kept separate from the
|
||||
// static catalog so plugin state never mutates the built-in array.
|
||||
const runtimePermissionGroups = new Map<string, PermissionCatalogEntry>();
|
||||
// Groups registered at runtime by plugins. Kept separate from the static
|
||||
// catalog so plugin state never mutates the built-in array.
|
||||
const pluginPermissionGroups = new Map<string, PermissionCatalogEntry>();
|
||||
|
||||
/**
|
||||
* Every permission string core has ever registered, loaded from
|
||||
* rbac_known_permissions at boot.
|
||||
*
|
||||
* This is what lets a role keep a plugin's permission while that plugin is
|
||||
* disabled or uninstalled. Without it, unregistering a group made
|
||||
* PUT /rbac/roles/:id reject the whole role, so an admin could not save an
|
||||
* unrelated change to any role that still held one.
|
||||
*/
|
||||
const knownPermissions = new Set<string>();
|
||||
const knownGroups = new Set<string>();
|
||||
|
||||
let validPermissionsCache: Set<string> | null = null;
|
||||
|
||||
function buildValidPermissions(): Set<string> {
|
||||
return new Set<string>(
|
||||
[...PERMISSION_CATALOG, ...runtimePermissionGroups.values()]
|
||||
.flatMap((entry) => [...entry.permissions, `${entry.group}.*`])
|
||||
.concat("*"),
|
||||
);
|
||||
const valid = new Set<string>(["*"]);
|
||||
for (const entry of [
|
||||
...PERMISSION_CATALOG,
|
||||
...pluginPermissionGroups.values(),
|
||||
]) {
|
||||
valid.add(`${entry.group}.*`);
|
||||
for (const permission of entry.permissions) valid.add(permission);
|
||||
}
|
||||
for (const permission of knownPermissions) valid.add(permission);
|
||||
for (const group of knownGroups) valid.add(`${group}.*`);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function getValidPermissions(): Set<string> {
|
||||
@@ -84,27 +98,73 @@ function getValidPermissions(): Set<string> {
|
||||
return validPermissionsCache;
|
||||
}
|
||||
|
||||
export function registerPermissionGroup(entry: PermissionCatalogEntry): void {
|
||||
runtimePermissionGroups.set(entry.group, entry);
|
||||
/** Records permissions as known, so they stay valid without the plugin. */
|
||||
export function rememberPermissions(permissions: Iterable<string>): void {
|
||||
for (const permission of permissions) {
|
||||
knownPermissions.add(permission);
|
||||
const group = permission.split(".")[0];
|
||||
if (group) knownGroups.add(group);
|
||||
}
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
export function unregisterPermissionGroup(group: string): void {
|
||||
runtimePermissionGroups.delete(group);
|
||||
/**
|
||||
* Records a group as known without naming a permission in it.
|
||||
*
|
||||
* A role can hold only "ai.*", with no concrete ai permission anywhere, and it
|
||||
* still has to keep saving while the plugin is off.
|
||||
*/
|
||||
export function rememberPermissionGroup(group: string): void {
|
||||
if (!group) return;
|
||||
knownGroups.add(group);
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
/** Seeds the known set at boot, before any request is served. */
|
||||
export function loadKnownPermissions(permissions: Iterable<string>): void {
|
||||
rememberPermissions(permissions);
|
||||
}
|
||||
|
||||
export function registerPluginPermissions(entry: PermissionCatalogEntry): void {
|
||||
pluginPermissionGroups.set(entry.group, { ...entry, enabled: true });
|
||||
rememberPermissions(entry.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a plugin's group disabled without removing it.
|
||||
*
|
||||
* The permissions stay valid, because a role that holds one is not wrong just
|
||||
* because the plugin is off. The editor greys the group instead.
|
||||
*/
|
||||
export function markPluginPermissionsDisabled(pluginId: string): void {
|
||||
for (const [group, entry] of pluginPermissionGroups) {
|
||||
if (entry.pluginId !== pluginId) continue;
|
||||
pluginPermissionGroups.set(group, { ...entry, enabled: false });
|
||||
}
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
// Static entries plus any groups registered at runtime.
|
||||
export function getPermissionCatalog(): PermissionCatalogEntry[] {
|
||||
return [...PERMISSION_CATALOG, ...runtimePermissionGroups.values()];
|
||||
return [...PERMISSION_CATALOG, ...pluginPermissionGroups.values()];
|
||||
}
|
||||
|
||||
export function isValidPermission(permission: string): boolean {
|
||||
return getValidPermissions().has(permission);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPermissionCatalog(): void {
|
||||
pluginPermissionGroups.clear();
|
||||
knownPermissions.clear();
|
||||
knownGroups.clear();
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
// What the seeded system roles grant. Applied only to a role row that has no
|
||||
// permissions yet, so an admin's edits to these roles survive restarts.
|
||||
// Plugin permissions are not listed here; a plugin asks for its own defaults
|
||||
// through contributes.permissions[].defaultRoles.
|
||||
export const SYSTEM_ROLE_DEFAULTS = {
|
||||
admin: {
|
||||
description: "Administrator with full access",
|
||||
@@ -112,12 +172,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
|
||||
},
|
||||
user: {
|
||||
description: "Regular user",
|
||||
permissions: [
|
||||
"hosts.*",
|
||||
"snippets.*",
|
||||
"automations.*",
|
||||
"credentials.*",
|
||||
"ai.*",
|
||||
],
|
||||
permissions: ["hosts.*", "credentials.*"],
|
||||
},
|
||||
} as const;
|
||||
@@ -481,7 +481,7 @@ class PermissionManager {
|
||||
}
|
||||
}
|
||||
|
||||
export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
|
||||
export { PermissionManager, SHARE_PERMISSION_LEVELS };
|
||||
export type {
|
||||
AuthenticatedRequest,
|
||||
HostAccessInfo,
|
||||
|
||||
@@ -36,7 +36,7 @@ export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
|
||||
return new ProxyAgent(proxyUrl) as unknown as Dispatcher;
|
||||
}
|
||||
|
||||
export function getFetchDispatcher(targetUrl: string): Dispatcher {
|
||||
function getFetchDispatcher(targetUrl: string): Dispatcher {
|
||||
return getProxyAgent(targetUrl) ?? (directAgent as unknown as Dispatcher);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ async function createSingleProxyConnection(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHttpConnectConnection(
|
||||
async function createHttpConnectConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
proxyHost: string,
|
||||
@@ -175,7 +175,7 @@ export async function createHttpConnectConnection(
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMixedProxyChainConnection(
|
||||
async function createMixedProxyChainConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
proxyChain: ProxyNode[],
|
||||
|
||||
@@ -109,7 +109,7 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export function getRequestOriginWithForceHTTPS(
|
||||
function getRequestOriginWithForceHTTPS(
|
||||
req: Request | IncomingMessage,
|
||||
): string {
|
||||
if (process.env.OIDC_FORCE_HTTPS === "true") {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { lookup, type LookupAddress, type LookupOptions } from "dns";
|
||||
import { BlockList, isIP } from "net";
|
||||
import { Agent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici-types";
|
||||
import { getProxyAgent } from "./proxy-agent.js";
|
||||
|
||||
type DnsLookupFn = (
|
||||
hostname: string,
|
||||
@@ -186,11 +188,19 @@ export async function readResponseTextLimited(
|
||||
return Buffer.concat(chunks, total).toString("utf8");
|
||||
}
|
||||
|
||||
/** undici's own default for both, kept as the floor. */
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
export function outboundIdleTimeout(timeoutMs?: number): number {
|
||||
return Math.max(timeoutMs ?? 0, DEFAULT_IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export async function safeOutboundFetch(
|
||||
rawUrl: string,
|
||||
options: RequestInit,
|
||||
allowedPrivateHosts: readonly string[] = [],
|
||||
tls: OutboundTlsOptions = {},
|
||||
idleTimeoutMs?: number,
|
||||
): Promise<Response> {
|
||||
const url = new URL(rawUrl);
|
||||
if (
|
||||
@@ -209,7 +219,27 @@ export async function safeOutboundFetch(
|
||||
throw new Error("Private destinations are not allowed");
|
||||
}
|
||||
|
||||
// An admin-allowlisted private host goes through the configured proxy,
|
||||
// when there is one, the same way core's own outbound calls do. A public
|
||||
// host never does: the proxy would resolve it, skipping the check above.
|
||||
const proxy =
|
||||
allowPrivate && !tls.ca && tls.rejectUnauthorized !== false
|
||||
? getProxyAgent(url.toString())
|
||||
: undefined;
|
||||
if (proxy) {
|
||||
return (await undiciFetch(url.toString(), {
|
||||
...options,
|
||||
dispatcher: proxy as unknown as Dispatcher,
|
||||
redirect: options.redirect === "manual" ? "manual" : "error",
|
||||
} as never)) as unknown as Response;
|
||||
}
|
||||
|
||||
// A slow model can go quiet for minutes mid-stream. Past bodyTimeout undici
|
||||
// kills the body with a bare "terminated".
|
||||
const idle = outboundIdleTimeout(idleTimeoutMs);
|
||||
const dispatcher = new Agent({
|
||||
headersTimeout: idle,
|
||||
bodyTimeout: idle,
|
||||
connect: {
|
||||
lookup: createDnsLookupHook(lookup, allowPrivate),
|
||||
...(tls.ca ? { ca: tls.ca } : {}),
|
||||
@@ -220,12 +250,16 @@ export async function safeOutboundFetch(
|
||||
});
|
||||
|
||||
try {
|
||||
return await undiciFetch(url.toString(), {
|
||||
const response = await undiciFetch(url.toString(), {
|
||||
...options,
|
||||
dispatcher,
|
||||
redirect: "error",
|
||||
redirect: options.redirect === "manual" ? "manual" : "error",
|
||||
});
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
// close() waits for the body, which the caller reads after we return.
|
||||
dispatcher.close().catch(() => {});
|
||||
return response;
|
||||
} catch (error) {
|
||||
await dispatcher.destroy().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import { parseNotificationAllowlist } from "./notification-egress.js";
|
||||
|
||||
/** Private hosts (a self-hosted 1Password Connect server) secret sources may reach. */
|
||||
export const SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY =
|
||||
"secret_source_private_endpoint_allowlist";
|
||||
|
||||
export async function readSecretSourcePrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseNotificationAllowlist(raw);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import http from "http";
|
||||
import type { RequestListener } from "http";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
interface ServiceListenOptions {
|
||||
app: RequestListener;
|
||||
port: number;
|
||||
logger: Logger;
|
||||
/** Used only to identify the service in the conflict log. */
|
||||
serviceName: string;
|
||||
host?: string;
|
||||
onListening?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one of the backend's fixed-port services.
|
||||
*
|
||||
* Express's `app.listen(port, host, callback)` registers that callback as
|
||||
* the server's `error` handler as well as its `listening` handler:
|
||||
*
|
||||
* if (typeof args[args.length - 1] === 'function') {
|
||||
* var done = args[args.length - 1] = once(args[args.length - 1])
|
||||
* server.once('error', done)
|
||||
* }
|
||||
*
|
||||
* so a port conflict called the service's "started" callback with an
|
||||
* EADDRINUSE error as its argument and emitted nothing. Since none of the
|
||||
* services read that argument, a failed bind was indistinguishable from a
|
||||
* successful one: the service logged that it had started, ran its
|
||||
* initialisation, and served nothing, with no error anywhere in the logs.
|
||||
*
|
||||
* Building the server here keeps `listening` and `error` separate. A port
|
||||
* conflict is treated the way database.ts already treats one on the main
|
||||
* HTTP port -- reported with the port that was taken, then exit -- rather
|
||||
* than leaving the backend up with one feature silently missing. Exiting
|
||||
* also gives the desktop app the classified failure it already knows how
|
||||
* to surface, so the user is told which port to free.
|
||||
*/
|
||||
/**
|
||||
* Reports a port conflict on an already-built service server and stops the
|
||||
* backend, for services that must own their `http.Server` (the tunnel
|
||||
* service attaches a WebSocket upgrade handler to it, for example) and so
|
||||
* cannot go through {@link listenOnServicePort}.
|
||||
*/
|
||||
export function attachServicePortConflictHandler(
|
||||
server: http.Server,
|
||||
port: number,
|
||||
logger: Logger,
|
||||
serviceName: string,
|
||||
): void {
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
logger.error(
|
||||
`Port ${port} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "service_port_conflict",
|
||||
port,
|
||||
service: serviceName,
|
||||
},
|
||||
);
|
||||
// process.exit() does not return, so nothing below runs in
|
||||
// production; the explicit return keeps that obvious and keeps the
|
||||
// rethrow below reachable only for genuinely unexpected errors.
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export function listenOnServicePort({
|
||||
app,
|
||||
port,
|
||||
logger,
|
||||
serviceName,
|
||||
host = "127.0.0.1",
|
||||
onListening,
|
||||
}: ServiceListenOptions): http.Server {
|
||||
const server = http.createServer(app);
|
||||
attachServicePortConflictHandler(server, port, logger, serviceName);
|
||||
server.listen(port, host, onListening);
|
||||
return server;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
AUTH_PROTOCOL_METADATA,
|
||||
isSupportedAuthOverrideProtocol,
|
||||
type AuthOverrideProtocol,
|
||||
} from "../../types/auth-protocols.js";
|
||||
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
|
||||
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
@@ -109,9 +106,9 @@ export class SharedHostAuthOverrideService {
|
||||
}
|
||||
|
||||
private requireSupportedProtocol(protocol: AuthOverrideProtocol): void {
|
||||
if (!isSupportedAuthOverrideProtocol(protocol)) {
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
throw new SharedHostAuthOverrideServiceError(
|
||||
`${AUTH_PROTOCOL_METADATA[protocol].label} authentication overrides are not supported yet`,
|
||||
`No plugin declares the ${protocol} protocol`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import {
|
||||
isSupportedAuthOverrideProtocol,
|
||||
type AuthOverrideProtocol,
|
||||
} from "../../types/auth-protocols.js";
|
||||
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
|
||||
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type {
|
||||
HostResolutionCredentialRecord,
|
||||
HostResolutionHostRecord,
|
||||
} from "../database/repositories/host-resolution-repository.js";
|
||||
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import {
|
||||
SharedHostSecretsManager,
|
||||
type SharedSecretData,
|
||||
@@ -33,20 +35,16 @@ export function requiresPersonalHostAuthentication(
|
||||
host: Pick<HostResolutionHostRecord, "credentialId" | "authType">,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): boolean {
|
||||
// Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only
|
||||
// SSH, which sits behind shareSshAuth, can leave a recipient without auth.
|
||||
// Owner auth for a plugin protocol is snapshotted for every recipient, so
|
||||
// only SSH, which sits behind shareSshAuth, can leave one without auth.
|
||||
if (protocol !== "ssh") return false;
|
||||
return (
|
||||
!!host.credentialId ||
|
||||
host.authType === "password" ||
|
||||
host.authType === "key" ||
|
||||
host.authType === "credential" ||
|
||||
host.authType === "agent"
|
||||
);
|
||||
if (host.credentialId) return true;
|
||||
ensureCoreSshAuthProviders();
|
||||
return !!getSshAuthProvider(host.authType ?? "none")?.requiresSecret;
|
||||
}
|
||||
|
||||
/** Whether the owner's auth for this protocol is available to recipients. */
|
||||
export function isOwnerAuthShared(
|
||||
function isOwnerAuthShared(
|
||||
host: Pick<HostResolutionHostRecord, "shareSshAuth">,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): boolean {
|
||||
@@ -64,10 +62,8 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
userId: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<RecipientSharedHostAuthResolution> {
|
||||
if (!isSupportedAuthOverrideProtocol(protocol)) {
|
||||
throw new Error(
|
||||
`${protocol.toUpperCase()} shared-host authentication is not implemented`,
|
||||
);
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
throw new Error(`No plugin declares the ${String(protocol)} protocol`);
|
||||
}
|
||||
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
@@ -107,12 +103,36 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
}
|
||||
|
||||
try {
|
||||
const secret =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
);
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
let secret = await secretsManager.getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
// No snapshot yet -- most often the grant was created while this
|
||||
// recipient's data key was unavailable (snapshotForUser silently
|
||||
// skips in that case). Mirrors the self-heal in findUsableCredential.
|
||||
const hostAccessId = await findActiveHostAccessId(hostId, userId);
|
||||
if (hostAccessId !== null) {
|
||||
const ownerId = await repository.findHostOwnerId(hostId);
|
||||
if (ownerId) {
|
||||
await secretsManager.snapshotForUser(
|
||||
hostAccessId,
|
||||
hostId,
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
secret = await secretsManager.getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (secret) {
|
||||
return {
|
||||
source: "owner-shared",
|
||||
@@ -129,3 +149,17 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
? { source: "required" }
|
||||
: { source: "secretless" };
|
||||
}
|
||||
|
||||
/** The grant id for this user's access to the host, direct or via a role. */
|
||||
async function findActiveHostAccessId(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const access = await createCurrentRbacAccessRepository().findActiveHostAccess(
|
||||
hostId,
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
return access?.id ?? null;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type { HostResolutionHostRecord } from "../database/repositories/host-res
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { findUsableCredential } from "../hosts/usable-credential.js";
|
||||
import { listProtocolLogins } from "../hosts/protocol-auth/protocol-auth.js";
|
||||
|
||||
export interface SharedSecretData {
|
||||
username?: string;
|
||||
@@ -22,6 +24,8 @@ export interface SharedSecretData {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
domain?: string;
|
||||
/** A plugin protocol's declared credential fields. */
|
||||
fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProtocolSnapshot {
|
||||
@@ -39,28 +43,38 @@ function snapshotRecordId(
|
||||
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
|
||||
}
|
||||
|
||||
// Mirrors the connection-type migration fallback in transformHostResponse():
|
||||
// old hosts only set connectionType, the per-protocol enable flags came later.
|
||||
function enabledProtocols(
|
||||
host: HostResolutionHostRecord,
|
||||
): Record<ShareProtocol, boolean> {
|
||||
// SSH follows its own switch. A plugin protocol's switch is that plugin's
|
||||
// host setting, and the editor removes a protocol's login when it is
|
||||
// switched off, so a snapshot is taken whenever a login is stored.
|
||||
function sshEnabled(host: HostResolutionHostRecord): boolean {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const telnet = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh";
|
||||
|
||||
return {
|
||||
ssh: isMigratedNonSsh ? false : host.enableSsh !== false,
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
telnet: isMigratedNonSsh ? ct === "telnet" : telnet,
|
||||
};
|
||||
return host.enableSsh !== false && (!ct || ct === "ssh");
|
||||
}
|
||||
|
||||
// Per-recipient copies of connection secrets, re-encrypted under the
|
||||
// recipient's DEK. SSH authentication is copied only when the host owner
|
||||
// explicitly opts in; recipient-owned credential overrides remain separate.
|
||||
/**
|
||||
* A snapshot's protocol fields. Snapshots taken before 2.9.0 kept a domain
|
||||
* in its own column and nothing else.
|
||||
*/
|
||||
function parseFields(
|
||||
value: string | undefined,
|
||||
legacyDomain: string | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable fields are left out.
|
||||
}
|
||||
}
|
||||
return legacyDomain ? { domain: legacyDomain } : undefined;
|
||||
}
|
||||
|
||||
class SharedHostSecretsManager {
|
||||
private static instance: SharedHostSecretsManager;
|
||||
|
||||
@@ -123,6 +137,10 @@ class SharedHostSecretsManager {
|
||||
),
|
||||
encryptedKeyType: snapshot.data.keyType || null,
|
||||
encryptedDomain: encrypt(snapshot.data.domain, "domain"),
|
||||
encryptedFields: encrypt(
|
||||
snapshot.data.fields ? JSON.stringify(snapshot.data.fields) : "",
|
||||
"fields",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -364,10 +382,9 @@ class SharedHostSecretsManager {
|
||||
ownerId: string,
|
||||
): Promise<ProtocolSnapshot[]> {
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const enabled = enabledProtocols(host);
|
||||
const snapshots: ProtocolSnapshot[] = [];
|
||||
|
||||
if (enabled.ssh && host.shareSshAuth) {
|
||||
if (sshEnabled(host) && host.shareSshAuth) {
|
||||
if (host.credentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.credentialId,
|
||||
@@ -408,106 +425,40 @@ class SharedHostSecretsManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.rdp) {
|
||||
const rdpAuthType =
|
||||
host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct");
|
||||
if (rdpAuthType === "credential" && host.rdpCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.rdpCredentialId,
|
||||
for (const login of await listProtocolLogins(host.id, ownerId)) {
|
||||
const fields = { ...login.fields, ...login.secretFields };
|
||||
const hasFields = Object.keys(fields).length > 0;
|
||||
if (login.authType === "credential" && login.credentialId) {
|
||||
const credential = await findUsableCredential(
|
||||
login.credentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "rdp",
|
||||
protocol: login.protocol,
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.rdpCredentialId,
|
||||
originalCredentialId: login.credentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
domain: host.rdpDomain || undefined,
|
||||
fields: hasFields ? fields : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.rdpUser || host.rdpPassword) {
|
||||
} else if (
|
||||
login.authType === "direct" &&
|
||||
(login.username || login.password || hasFields)
|
||||
) {
|
||||
snapshots.push({
|
||||
protocol: "rdp",
|
||||
protocol: login.protocol,
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.rdpUser || undefined,
|
||||
username: login.username || undefined,
|
||||
authType: "direct",
|
||||
password: host.rdpPassword || undefined,
|
||||
domain: host.rdpDomain || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.vnc) {
|
||||
const vncAuthType =
|
||||
host.vncAuthType || (host.vncCredentialId ? "credential" : "direct");
|
||||
if (vncAuthType === "credential" && host.vncCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.vncCredentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "vnc",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.vncCredentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.vncUser || host.vncPassword) {
|
||||
snapshots.push({
|
||||
protocol: "vnc",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.vncUser || undefined,
|
||||
authType: "direct",
|
||||
password: host.vncPassword || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.telnet) {
|
||||
const telnetAuthType =
|
||||
host.telnetAuthType ||
|
||||
(host.telnetCredentialId ? "credential" : "direct");
|
||||
if (telnetAuthType === "credential" && host.telnetCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.telnetCredentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "telnet",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.telnetCredentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.telnetUser || host.telnetPassword) {
|
||||
snapshots.push({
|
||||
protocol: "telnet",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.telnetUser || undefined,
|
||||
authType: "direct",
|
||||
password: host.telnetPassword || undefined,
|
||||
password: login.password || undefined,
|
||||
fields: hasFields ? fields : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -530,6 +481,7 @@ class SharedHostSecretsManager {
|
||||
? FieldCrypto.decryptField(value, userDEK, recordId, fieldName)
|
||||
: undefined;
|
||||
|
||||
const domain = decrypt(secret.encryptedDomain, "domain");
|
||||
return {
|
||||
username: decrypt(secret.encryptedUsername, "username"),
|
||||
authType: secret.encryptedAuthType || "password",
|
||||
@@ -537,7 +489,8 @@ class SharedHostSecretsManager {
|
||||
key: decrypt(secret.encryptedKey, "key"),
|
||||
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
|
||||
keyType: secret.encryptedKeyType || undefined,
|
||||
domain: decrypt(secret.encryptedDomain, "domain"),
|
||||
domain,
|
||||
fields: parseFields(decrypt(secret.encryptedFields, "fields"), domain),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
export {
|
||||
createSocks5Connection,
|
||||
createHttpConnectConnection,
|
||||
createMixedProxyChainConnection,
|
||||
testProxyConnectivity,
|
||||
} from "./proxy-helper.js";
|
||||
export { createSocks5Connection } from "./proxy-helper.js";
|
||||
export type { SOCKS5Config } from "./proxy-helper.js";
|
||||
@@ -122,49 +122,3 @@ export function buildSSHAlgorithms(
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
};
|
||||
}
|
||||
|
||||
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: filterCiphers([
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
]),
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
};
|
||||
@@ -228,7 +228,7 @@ export interface KeyPairValidationResult {
|
||||
|
||||
const PUTTY_PRIVATE_KEY_RE = /^PuTTY-User-Key-File-(\d+):\s*(.+)$/m;
|
||||
|
||||
export function normalizePrivateKeyText(privateKeyData: string): string {
|
||||
function normalizePrivateKeyText(privateKeyData: string): string {
|
||||
return privateKeyData.trim().replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
|
||||
/**
|
||||
* A minimal client for smallstep's step-ca SSH user-certificate flow, done
|
||||
* over its HTTP API rather than the `step` binary:
|
||||
*
|
||||
* 1. bootstrap the CA's root certificate by fingerprint (GET /root/{fp})
|
||||
* 2. read the OIDC provisioner's client settings (GET /provisioners)
|
||||
* 3. run the OIDC authorization-code flow against the provider
|
||||
* 4. POST the id_token as the one-time token to /1.0/ssh/sign
|
||||
*/
|
||||
|
||||
export interface StepCaTarget {
|
||||
caUrl: string;
|
||||
fingerprint: string;
|
||||
/** Hosts the SSRF guard may reach even when they resolve to private ranges. */
|
||||
allowedPrivateHosts: readonly string[];
|
||||
}
|
||||
|
||||
export interface StepCaOidcProvisioner {
|
||||
name: string;
|
||||
clientID: string;
|
||||
clientSecret?: string;
|
||||
configurationEndpoint: string;
|
||||
}
|
||||
|
||||
export interface OidcEndpoints {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Display-only claims; the CA is the component that verifies the token. */
|
||||
export function decodeJwtClaims(token: string): Record<string, unknown> {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCaUrl(raw: string): string {
|
||||
const url = new URL(raw.trim());
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Step CA URL must use https");
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeFingerprint(raw: string): string {
|
||||
const hex = raw.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
|
||||
if (hex.length !== 64) {
|
||||
throw new Error("CA fingerprint must be a SHA-256 hex digest");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
export function pemToDer(pem: string): Buffer {
|
||||
const body = pem
|
||||
.replace(/-----BEGIN [^-]+-----/g, "")
|
||||
.replace(/-----END [^-]+-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
return Buffer.from(body, "base64");
|
||||
}
|
||||
|
||||
export function certificateFingerprint(pem: string): string {
|
||||
return crypto.createHash("sha256").update(pemToDer(pem)).digest("hex");
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, what: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${what} failed: HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root endpoint is served under the CA's own TLS certificate, which
|
||||
* nothing trusts yet - so this one request skips verification and trusts
|
||||
* the fingerprint instead, exactly like `step ca bootstrap`.
|
||||
*/
|
||||
export async function fetchRootCertificate(
|
||||
target: StepCaTarget,
|
||||
): Promise<string> {
|
||||
const fingerprint = normalizeFingerprint(target.fingerprint);
|
||||
const response = await safeOutboundFetch(
|
||||
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
|
||||
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||
target.allowedPrivateHosts,
|
||||
{ rejectUnauthorized: false },
|
||||
);
|
||||
const { ca } = await readJson<{ ca?: string }>(
|
||||
response,
|
||||
"Fetching the CA root",
|
||||
);
|
||||
if (!ca || certificateFingerprint(ca) !== fingerprint) {
|
||||
throw new Error("CA root certificate does not match the fingerprint");
|
||||
}
|
||||
return ca;
|
||||
}
|
||||
|
||||
export async function findOidcProvisioner(
|
||||
target: StepCaTarget,
|
||||
rootPem: string,
|
||||
name: string,
|
||||
): Promise<StepCaOidcProvisioner> {
|
||||
const base = normalizeCaUrl(target.caUrl);
|
||||
let cursor = "";
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const response = await safeOutboundFetch(
|
||||
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
|
||||
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||
target.allowedPrivateHosts,
|
||||
{ ca: rootPem },
|
||||
);
|
||||
const body = await readJson<{
|
||||
provisioners?: Array<Record<string, unknown>>;
|
||||
nextCursor?: string;
|
||||
}>(response, "Listing CA provisioners");
|
||||
const match = (body.provisioners ?? []).find(
|
||||
(p) => p.name === name && p.type === "OIDC",
|
||||
);
|
||||
if (match) {
|
||||
if (
|
||||
typeof match.clientID !== "string" ||
|
||||
typeof match.configurationEndpoint !== "string"
|
||||
) {
|
||||
throw new Error("The OIDC provisioner is missing its client settings");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
clientID: match.clientID,
|
||||
clientSecret:
|
||||
typeof match.clientSecret === "string"
|
||||
? match.clientSecret
|
||||
: undefined,
|
||||
configurationEndpoint: match.configurationEndpoint,
|
||||
};
|
||||
}
|
||||
if (!body.nextCursor) break;
|
||||
cursor = body.nextCursor;
|
||||
}
|
||||
throw new Error(`OIDC provisioner "${name}" not found on the CA`);
|
||||
}
|
||||
|
||||
export async function discoverOidcEndpoints(
|
||||
configurationEndpoint: string,
|
||||
allowedPrivateHosts: readonly string[],
|
||||
): Promise<OidcEndpoints> {
|
||||
const response = await safeOutboundFetch(
|
||||
configurationEndpoint,
|
||||
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||
allowedPrivateHosts,
|
||||
);
|
||||
const doc = await readJson<{
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
}>(response, "OIDC discovery");
|
||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||
throw new Error("OIDC discovery document is incomplete");
|
||||
}
|
||||
return {
|
||||
authorizationEndpoint: doc.authorization_endpoint,
|
||||
tokenEndpoint: doc.token_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPkce(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(48).toString("base64url");
|
||||
const challenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function buildAuthorizationUrl(input: {
|
||||
authorizationEndpoint: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
}): string {
|
||||
const url = new URL(input.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", input.clientId);
|
||||
url.searchParams.set("redirect_uri", input.redirectUri);
|
||||
url.searchParams.set("scope", "openid email profile");
|
||||
url.searchParams.set("state", input.state);
|
||||
url.searchParams.set("nonce", input.nonce);
|
||||
url.searchParams.set("code_challenge", input.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCodeForIdToken(input: {
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
allowedPrivateHosts: readonly string[];
|
||||
}): Promise<string> {
|
||||
const form = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
client_id: input.clientId,
|
||||
code_verifier: input.codeVerifier,
|
||||
});
|
||||
if (input.clientSecret) form.set("client_secret", input.clientSecret);
|
||||
const response = await safeOutboundFetch(
|
||||
input.tokenEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
},
|
||||
input.allowedPrivateHosts,
|
||||
);
|
||||
const body = await readJson<{ id_token?: string }>(
|
||||
response,
|
||||
"OIDC token exchange",
|
||||
);
|
||||
if (!body.id_token) {
|
||||
throw new Error("The identity provider returned no id_token");
|
||||
}
|
||||
return body.id_token;
|
||||
}
|
||||
|
||||
// --- SSH keys and certificates ---------------------------------------------
|
||||
|
||||
function sshString(value: Buffer | string): Buffer {
|
||||
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
return Buffer.concat([len, data]);
|
||||
}
|
||||
|
||||
export function generateSshKeyPair(): {
|
||||
publicKeyLine: string;
|
||||
privateKeyPem: string;
|
||||
} {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const raw = Buffer.from(jwk.x, "base64url");
|
||||
const blob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
|
||||
return {
|
||||
publicKeyLine: `ssh-ed25519 ${blob.toString("base64")}`,
|
||||
privateKeyPem: privateKey
|
||||
.export({ format: "pem", type: "pkcs8" })
|
||||
.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signSshCertificate(
|
||||
target: StepCaTarget,
|
||||
rootPem: string,
|
||||
input: {
|
||||
publicKeyLine: string;
|
||||
ott: string;
|
||||
principals: string[];
|
||||
keyId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const blob = input.publicKeyLine.trim().split(/\s+/)[1];
|
||||
if (!blob) throw new Error("Invalid public key line");
|
||||
const response = await safeOutboundFetch(
|
||||
`${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
publicKey: blob,
|
||||
ott: input.ott,
|
||||
certType: "user",
|
||||
principals: input.principals,
|
||||
keyID: input.keyId,
|
||||
}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
},
|
||||
target.allowedPrivateHosts,
|
||||
{ ca: rootPem },
|
||||
);
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const err = (await response.json()) as { message?: string };
|
||||
detail = err.message ? `: ${err.message}` : "";
|
||||
} catch {
|
||||
/* no body */
|
||||
}
|
||||
throw new Error(
|
||||
`The CA refused to sign the key (HTTP ${response.status})${detail}`,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as { crt?: string };
|
||||
if (!body.crt) throw new Error("The CA returned no certificate");
|
||||
return body.crt.trim();
|
||||
}
|
||||
|
||||
export interface SshCertificateInfo {
|
||||
keyType: string;
|
||||
publicKeyLine: string;
|
||||
keyId: string;
|
||||
principals: string[];
|
||||
validAfter: Date;
|
||||
validBefore: Date;
|
||||
}
|
||||
|
||||
/** Reads the identity and validity window out of an OpenSSH certificate line. */
|
||||
export function parseSshCertificate(line: string): SshCertificateInfo {
|
||||
const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
|
||||
if (blob.length === 0) throw new Error("Invalid SSH certificate");
|
||||
let offset = 0;
|
||||
const readString = (): Buffer => {
|
||||
if (offset + 4 > blob.length) throw new Error("Truncated SSH certificate");
|
||||
const len = blob.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
if (offset + len > blob.length) {
|
||||
throw new Error("Truncated SSH certificate");
|
||||
}
|
||||
const value = blob.subarray(offset, offset + len);
|
||||
offset += len;
|
||||
return value;
|
||||
};
|
||||
const readUint64 = (): bigint => {
|
||||
if (offset + 8 > blob.length) throw new Error("Truncated SSH certificate");
|
||||
const value = blob.readBigUInt64BE(offset);
|
||||
offset += 8;
|
||||
return value;
|
||||
};
|
||||
|
||||
const keyType = readString().toString();
|
||||
if (!keyType.endsWith("-cert-v01@openssh.com")) {
|
||||
throw new Error("The CA returned a public key instead of a certificate");
|
||||
}
|
||||
readString(); // nonce
|
||||
// Public key fields differ by algorithm; consume them by shape.
|
||||
const publicKeyParts: Buffer[] = [];
|
||||
let plainKeyType: string;
|
||||
if (keyType.startsWith("ssh-rsa")) {
|
||||
plainKeyType = "ssh-rsa";
|
||||
publicKeyParts.push(readString(), readString()); // e, n
|
||||
} else if (keyType.startsWith("ecdsa-")) {
|
||||
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
|
||||
publicKeyParts.push(readString(), readString()); // curve, Q
|
||||
} else {
|
||||
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
|
||||
publicKeyParts.push(readString()); // ed25519 pk
|
||||
}
|
||||
const publicKeyBlob = Buffer.concat([
|
||||
sshString(plainKeyType),
|
||||
...publicKeyParts.map(sshString),
|
||||
]);
|
||||
readUint64(); // serial
|
||||
offset += 4; // type
|
||||
const keyId = readString().toString();
|
||||
const principalsBlob = readString();
|
||||
const principals: string[] = [];
|
||||
for (let p = 0; p < principalsBlob.length;) {
|
||||
if (p + 4 > principalsBlob.length) {
|
||||
throw new Error("Invalid SSH certificate principals");
|
||||
}
|
||||
const len = principalsBlob.readUInt32BE(p);
|
||||
if (p + 4 + len > principalsBlob.length) {
|
||||
throw new Error("Invalid SSH certificate principals");
|
||||
}
|
||||
principals.push(principalsBlob.subarray(p + 4, p + 4 + len).toString());
|
||||
p += 4 + len;
|
||||
}
|
||||
const validAfter = readUint64();
|
||||
const validBefore = readUint64();
|
||||
const toDate = (seconds: bigint) =>
|
||||
new Date(
|
||||
Number(seconds > 8_640_000_000_000n ? 8_640_000_000_000n : seconds) *
|
||||
1000,
|
||||
);
|
||||
return {
|
||||
keyType,
|
||||
publicKeyLine: `${plainKeyType} ${publicKeyBlob.toString("base64")}`,
|
||||
keyId,
|
||||
principals,
|
||||
validAfter: toDate(validAfter),
|
||||
validBefore: toDate(validBefore),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import { parseNotificationAllowlist } from "./notification-egress.js";
|
||||
|
||||
/** Private hosts (a CA on the LAN, an internal IdP) the Step CA flow may reach. */
|
||||
export const STEP_CA_PRIVATE_ALLOWLIST_KEY =
|
||||
"step_ca_private_endpoint_allowlist";
|
||||
|
||||
export async function readStepCaPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
STEP_CA_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseNotificationAllowlist(raw);
|
||||
}
|
||||
+44
-121
@@ -8,6 +8,42 @@ type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// dist/backend/backend/utils -> the repository.
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
/**
|
||||
* A tag per plugin, named and described by its manifest, so the reference
|
||||
* groups a plugin's routes without core naming any plugin.
|
||||
*/
|
||||
async function pluginTags(): Promise<{ name: string; description: string }[]> {
|
||||
const pluginsDir = path.join(REPO_ROOT, "plugins");
|
||||
const tags: { name: string; description: string }[] = [];
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(pluginsDir);
|
||||
} catch {
|
||||
return tags;
|
||||
}
|
||||
for (const entry of entries.sort()) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(pluginsDir, entry, "manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { name?: string; description?: string };
|
||||
if (manifest.name) {
|
||||
tags.push({
|
||||
name: manifest.name,
|
||||
description: manifest.description ?? "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not a plugin folder.
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
const swaggerOptions: SwaggerJSDocOptions = {
|
||||
definition: {
|
||||
@@ -22,30 +58,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30001",
|
||||
description: "Main database and authentication server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30003",
|
||||
description: "SSH tunnel management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30004",
|
||||
description: "SSH file manager server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30005",
|
||||
description: "Server statistics and monitoring server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30006",
|
||||
description: "Dashboard server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30007",
|
||||
description: "Docker management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30011",
|
||||
description: "Serial connection server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
@@ -71,34 +83,14 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "AI",
|
||||
description: "AI assistant providers, conversations and proposals",
|
||||
},
|
||||
{
|
||||
name: "Alerts",
|
||||
description: "System alerts and notifications management",
|
||||
},
|
||||
{
|
||||
name: "Credentials",
|
||||
description: "SSH credential management",
|
||||
},
|
||||
{
|
||||
name: "Network Topology",
|
||||
description: "Network topology visualization and management",
|
||||
},
|
||||
{
|
||||
name: "RBAC",
|
||||
description: "Role-based access control for host sharing",
|
||||
},
|
||||
{
|
||||
name: "Snippets",
|
||||
description: "Command snippet management",
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
description: "Terminal command history",
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
description: "User management and authentication",
|
||||
@@ -107,22 +99,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "Dashboard",
|
||||
description: "Dashboard statistics and activity",
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
description: "Docker container management",
|
||||
},
|
||||
{
|
||||
name: "SSH Tunnels",
|
||||
description: "SSH tunnel connection management",
|
||||
},
|
||||
{
|
||||
name: "Host Metrics",
|
||||
description: "Host status monitoring, metrics collection, and managers",
|
||||
},
|
||||
{
|
||||
name: "File Manager",
|
||||
description: "SSH file management operations",
|
||||
},
|
||||
{
|
||||
name: "SSH",
|
||||
description: "SSH host management and configuration",
|
||||
@@ -131,46 +107,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "Host Enrollment",
|
||||
description: "Host enrollment and onboarding",
|
||||
},
|
||||
{
|
||||
name: "Fleets",
|
||||
description: "Fleet grouping, membership, and inventory",
|
||||
},
|
||||
{
|
||||
name: "Workspaces",
|
||||
description: "Saved tab and split layouts",
|
||||
},
|
||||
{
|
||||
name: "Open Tabs",
|
||||
description: "Per-user open tab state",
|
||||
},
|
||||
{
|
||||
name: "Automations",
|
||||
description: "Scheduled and triggered automations",
|
||||
},
|
||||
{
|
||||
name: "Guacamole",
|
||||
description: "RDP, VNC, and Telnet remote desktop sessions",
|
||||
},
|
||||
{
|
||||
name: "Proxmox",
|
||||
description: "Proxmox host integration",
|
||||
},
|
||||
{
|
||||
name: "Proxmox Stats",
|
||||
description: "Proxmox node and VM statistics",
|
||||
},
|
||||
{
|
||||
name: "Session Sharing",
|
||||
description: "Live terminal session collaboration",
|
||||
},
|
||||
{
|
||||
name: "Session Logs",
|
||||
description: "Session recording and playback",
|
||||
},
|
||||
{
|
||||
name: "Homepage",
|
||||
description: "Homepage service links and layout",
|
||||
},
|
||||
{
|
||||
name: "Audit",
|
||||
description: "Audit log querying and export",
|
||||
@@ -179,34 +119,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "API Keys",
|
||||
description: "API key management",
|
||||
},
|
||||
{
|
||||
name: "SSO",
|
||||
description: "Single sign-on provider configuration",
|
||||
},
|
||||
{
|
||||
name: "WebAuthn",
|
||||
description: "Passkey registration and authentication",
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
description: "HashiCorp Vault SSH signing profiles",
|
||||
},
|
||||
{
|
||||
name: "Termix ID",
|
||||
description: "Built-in SSH certificate authority",
|
||||
},
|
||||
{
|
||||
name: "Tailscale",
|
||||
description: "Tailscale network integration",
|
||||
},
|
||||
{
|
||||
name: "Sync",
|
||||
description: "Remote sync between desktop and server",
|
||||
},
|
||||
{
|
||||
name: "Tunnel Presets",
|
||||
description: "Saved tunnel configurations",
|
||||
},
|
||||
{
|
||||
name: "User Preferences",
|
||||
description: "Per-user application preferences",
|
||||
@@ -226,13 +142,18 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
path.join(__dirname, "..", "database", "database.js").replace(/\\/g, "/"),
|
||||
path
|
||||
.join(__dirname, "..", "database", "routes", "*.js")
|
||||
.replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "ai", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
|
||||
// Plugin routes document themselves. Read from source, because a plugin's
|
||||
// bundle drops comments.
|
||||
path
|
||||
.join(REPO_ROOT, "plugins", "*", "src", "backend", "**", "*.ts")
|
||||
.replace(/\\/g, "/"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -242,6 +163,8 @@ async function generateOpenAPISpec() {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const definition = swaggerOptions.definition as { tags?: unknown[] };
|
||||
definition.tags = [...(definition.tags ?? []), ...(await pluginTags())];
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(
|
||||
|
||||
@@ -85,37 +85,3 @@ export async function decryptSystemSecret(stored: string): Promise<string> {
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Fields inside an SSO provider config that must not be stored readable. */
|
||||
export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const;
|
||||
|
||||
export async function encryptSsoConfigSecrets(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out = { ...config };
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
const value = out[field];
|
||||
if (typeof value === "string" && value) {
|
||||
out[field] = await encryptSystemSecret(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptSsoConfigSecrets(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out = { ...config };
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
const value = out[field];
|
||||
if (typeof value === "string" && value) {
|
||||
try {
|
||||
out[field] = await decryptSystemSecret(value);
|
||||
} catch {
|
||||
// A secret we cannot read must not take the whole provider down;
|
||||
// login will fail with a clearer error downstream.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentHostProtocolAuthRepository,
|
||||
createCurrentUserDataExportRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { decryptProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
|
||||
import { toPortableLogins } from "../hosts/protocol-auth/protocol-auth.js";
|
||||
import { readUserPluginRows } from "../plugins/user-data.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
@@ -16,13 +17,8 @@ interface UserExportData {
|
||||
userData: {
|
||||
sshHosts: unknown[];
|
||||
sshCredentials: unknown[];
|
||||
fileManagerData: {
|
||||
recent: unknown[];
|
||||
pinned: unknown[];
|
||||
shortcuts: unknown[];
|
||||
transferRecent: unknown[];
|
||||
};
|
||||
dismissedAlerts: unknown[];
|
||||
/** The user's rows in plugin tables, keyed by table name. */
|
||||
pluginData: Record<string, unknown[]>;
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
@@ -66,12 +62,28 @@ class UserDataExport {
|
||||
|
||||
const exportRepository = createCurrentUserDataExportRepository();
|
||||
const sshHosts = await exportRepository.listHostsByUserId(userId);
|
||||
const processedSshHosts =
|
||||
// Each host's plugin protocol logins ride on it, sealed like the
|
||||
// host's own secrets unless the export is plaintext.
|
||||
const logins = new Map<number, Record<string, unknown>>();
|
||||
for (const row of await createCurrentHostProtocolAuthRepository().listRowsForUser(
|
||||
userId,
|
||||
)) {
|
||||
const own = logins.get(row.hostId) ?? {};
|
||||
own[row.protocol] =
|
||||
format === "plaintext" && userDataKey
|
||||
? toPortableLogins([decryptProtocolLogin(row, userDataKey)])[
|
||||
row.protocol
|
||||
]
|
||||
: row;
|
||||
logins.set(row.hostId, own);
|
||||
}
|
||||
const processedSshHosts = (
|
||||
format === "plaintext" && userDataKey
|
||||
? sshHosts.map((host) =>
|
||||
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
|
||||
)
|
||||
: sshHosts;
|
||||
: sshHosts
|
||||
).map((host) => ({ ...host, protocolAuth: logins.get(host.id) ?? {} }));
|
||||
|
||||
let sshCredentialsData: unknown[] = [];
|
||||
if (includeCredentials) {
|
||||
@@ -90,22 +102,11 @@ class UserDataExport {
|
||||
: credentials;
|
||||
}
|
||||
|
||||
const [recentFiles, pinnedFiles, shortcuts, transferRecentData] =
|
||||
await Promise.all([
|
||||
createCurrentFileManagerBookmarkRepository().listRecentByUserId(
|
||||
userId,
|
||||
),
|
||||
createCurrentFileManagerBookmarkRepository().listPinnedByUserId(
|
||||
userId,
|
||||
),
|
||||
createCurrentFileManagerBookmarkRepository().listShortcutsByUserId(
|
||||
userId,
|
||||
),
|
||||
createCurrentTransferRecentRepository().listByUserId(userId),
|
||||
]);
|
||||
|
||||
const alerts =
|
||||
await createCurrentDismissedAlertRepository().listByUserId(userId);
|
||||
const pluginData = await readUserPluginRows(userId);
|
||||
const pluginRowCount = Object.values(pluginData).reduce(
|
||||
(total, rows) => total + rows.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
@@ -115,23 +116,13 @@ class UserDataExport {
|
||||
userData: {
|
||||
sshHosts: processedSshHosts,
|
||||
sshCredentials: sshCredentialsData,
|
||||
fileManagerData: {
|
||||
recent: recentFiles,
|
||||
pinned: pinnedFiles,
|
||||
shortcuts: shortcuts,
|
||||
transferRecent: transferRecentData,
|
||||
},
|
||||
dismissedAlerts: alerts,
|
||||
pluginData,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords:
|
||||
processedSshHosts.length +
|
||||
sshCredentialsData.length +
|
||||
recentFiles.length +
|
||||
pinnedFiles.length +
|
||||
shortcuts.length +
|
||||
transferRecentData.length +
|
||||
alerts.length,
|
||||
pluginRowCount,
|
||||
encrypted: format === "encrypted",
|
||||
exportType: scope,
|
||||
},
|
||||
@@ -203,37 +194,22 @@ class UserDataExport {
|
||||
|
||||
if (dataObj.userData) {
|
||||
const userData = dataObj.userData as Record<string, unknown>;
|
||||
const requiredFields = [
|
||||
"sshHosts",
|
||||
"sshCredentials",
|
||||
"fileManagerData",
|
||||
"dismissedAlerts",
|
||||
];
|
||||
const requiredFields = ["sshHosts", "sshCredentials"];
|
||||
for (const field of requiredFields) {
|
||||
if (
|
||||
!Array.isArray(userData[field]) &&
|
||||
!(field === "fileManagerData" && typeof userData[field] === "object")
|
||||
) {
|
||||
if (!Array.isArray(userData[field])) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
userData.fileManagerData &&
|
||||
typeof userData.fileManagerData === "object"
|
||||
userData.pluginData !== undefined &&
|
||||
(typeof userData.pluginData !== "object" ||
|
||||
userData.pluginData === null ||
|
||||
Object.values(userData.pluginData).some(
|
||||
(rows) => !Array.isArray(rows),
|
||||
))
|
||||
) {
|
||||
const fileManagerData = userData.fileManagerData as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fmFields = ["recent", "pinned", "shortcuts"];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(fileManagerData[field])) {
|
||||
errors.push(
|
||||
`Missing or invalid userData.fileManagerData.${field} field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
errors.push("Missing or invalid userData.pluginData field");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +224,7 @@ class UserDataExport {
|
||||
breakdown: {
|
||||
sshHosts: number;
|
||||
sshCredentials: number;
|
||||
fileManagerItems: number;
|
||||
dismissedAlerts: number;
|
||||
pluginRows: number;
|
||||
};
|
||||
encrypted: boolean;
|
||||
} {
|
||||
@@ -261,11 +236,10 @@ class UserDataExport {
|
||||
breakdown: {
|
||||
sshHosts: data.userData.sshHosts.length,
|
||||
sshCredentials: data.userData.sshCredentials.length,
|
||||
fileManagerItems:
|
||||
data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
pluginRows: Object.values(data.userData.pluginData ?? {}).reduce(
|
||||
(total, rows) => total + rows.length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface UserEncryptionMigrationRecord {
|
||||
export interface UserEncryptionMigrationStore {
|
||||
listHostRecords(userId: string): UserEncryptionMigrationRecord[];
|
||||
listCredentialRecords(userId: string): UserEncryptionMigrationRecord[];
|
||||
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined;
|
||||
updateHostSensitiveFields(
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
@@ -17,10 +16,6 @@ export interface UserEncryptionMigrationStore {
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
): void;
|
||||
updateUserSensitiveFields(
|
||||
userId: string,
|
||||
record: Record<string, unknown>,
|
||||
): void;
|
||||
updatePasswordResetFields(
|
||||
table: "ssh_data" | "ssh_credentials" | "users",
|
||||
recordId: number | string,
|
||||
@@ -52,10 +47,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
.all(userId);
|
||||
}
|
||||
|
||||
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined {
|
||||
return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
}
|
||||
|
||||
updateHostSensitiveFields(
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
@@ -64,7 +55,7 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
.prepare(
|
||||
`
|
||||
UPDATE ssh_data
|
||||
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET password = ?, key = ?, key_password = ?, key_type = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
@@ -73,9 +64,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
record.key || null,
|
||||
record.key_password || null,
|
||||
record.key_type || null,
|
||||
record.autostart_password || null,
|
||||
record.autostart_key || null,
|
||||
record.autostart_key_password || null,
|
||||
record.sudo_password || null,
|
||||
recordId,
|
||||
);
|
||||
@@ -104,27 +92,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
);
|
||||
}
|
||||
|
||||
updateUserSensitiveFields(
|
||||
userId: string,
|
||||
record: Record<string, unknown>,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
record.totp_secret || null,
|
||||
record.totp_backup_codes || null,
|
||||
record.client_secret || null,
|
||||
record.oidc_identifier || null,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
updatePasswordResetFields(
|
||||
table: "ssh_data" | "ssh_credentials" | "users",
|
||||
recordId: number | string,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import dgram from "dgram";
|
||||
|
||||
const MAC_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function parseMac(mac: string): Buffer {
|
||||
return Buffer.from(mac.replace(/[:-]/g, ""), "hex");
|
||||
}
|
||||
|
||||
export function buildMagicPacket(mac: string): Buffer {
|
||||
const macBytes = parseMac(mac);
|
||||
const packet = Buffer.alloc(102);
|
||||
packet.fill(0xff, 0, 6);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
macBytes.copy(packet, 6 + i * 6);
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
export function isValidMac(mac: string): boolean {
|
||||
return MAC_REGEX.test(mac);
|
||||
}
|
||||
|
||||
export function sendWakeOnLan(
|
||||
mac: string,
|
||||
broadcastAddress = "255.255.255.255",
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isValidMac(mac)) {
|
||||
return reject(new Error("Invalid MAC address"));
|
||||
}
|
||||
|
||||
const packet = buildMagicPacket(mac);
|
||||
const socket = dgram.createSocket("udp4");
|
||||
|
||||
socket.once("error", (err) => {
|
||||
socket.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(packet, 0, packet.length, 9, broadcastAddress, (err) => {
|
||||
socket.close();
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,50 @@ import type { IncomingMessage } from "http";
|
||||
|
||||
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||
|
||||
/**
|
||||
* A browser attaches the jwt cookie to a socket opened from any page on the
|
||||
* same site, including a sibling subdomain. The cookie only counts when the
|
||||
* page that opened the socket is this server. A non-web origin (the desktop
|
||||
* app) or no Origin at all is not a browser page on another host.
|
||||
*/
|
||||
export function isCookieOriginAllowed(req: IncomingMessage): boolean {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true;
|
||||
const hosts = [req.headers.host, req.headers["x-forwarded-host"]]
|
||||
.flatMap((value) => String(value ?? "").split(","))
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (hosts.includes(parsed.host.toLowerCase())) return true;
|
||||
// The Vite dev proxy rewrites Host, so loopback to loopback is allowed.
|
||||
if (
|
||||
isLoopback(parsed.hostname) &&
|
||||
hosts.some((host) => isLoopback(host.replace(/:\d+$/, "")))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (process.env.TERMIX_ALLOWED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.includes(parsed.origin.toLowerCase());
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "localhost" || host === "::1" || host.startsWith("127.");
|
||||
}
|
||||
|
||||
export function extractWebSocketToken(
|
||||
req: IncomingMessage,
|
||||
): string | undefined {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
if (cookieHeader && isCookieOriginAllowed(req)) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RawData } from "ws";
|
||||
// Cap on a single decoded text frame. Anything larger is almost certainly
|
||||
// abuse - the legitimate control messages here are tiny, and terminal input is
|
||||
// bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
export class WsMessageError extends Error {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user