mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +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,441 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: Buffer;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
certPublicKey?: string;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
useWarpgate?: boolean;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
warpgateAuthPromptSent: boolean;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
warpgateAuthTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async resolveCredentials(
|
||||
hostConfig: HostConfig,
|
||||
): Promise<ResolvedCredentials> {
|
||||
let resolvedCredentials: ResolvedCredentials = {
|
||||
username: hostConfig.username,
|
||||
authType: hostConfig.authType || "none",
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const cred =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
hostConfig.credentialId,
|
||||
this.context.userId,
|
||||
);
|
||||
|
||||
if (cred) {
|
||||
// Credentials may hold secret references instead of secrets.
|
||||
await resolveExternalSecretRefs(
|
||||
cred as unknown as Record<string, unknown>,
|
||||
this.context.userId,
|
||||
);
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
key:
|
||||
cred.key || cred.privateKey
|
||||
? Buffer.from((cred.key || cred.privateKey) as string)
|
||||
: undefined,
|
||||
keyPassword: (cred.keyPassword as string) || undefined,
|
||||
authType: (cred.authType as string) || "none",
|
||||
certPublicKey: (cred.certPublicKey as string) || undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (hostConfig.password) {
|
||||
resolvedCredentials.password = hostConfig.password;
|
||||
resolvedCredentials.authType = "password";
|
||||
}
|
||||
|
||||
if (hostConfig.key) {
|
||||
resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8");
|
||||
resolvedCredentials.authType = "key";
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
resolvedCredentials.keyPassword = hostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedCredentials;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
|
||||
const warpgatePattern = /warpgate\s+authentication/i;
|
||||
const isWarpgate =
|
||||
warpgatePattern.test(name) ||
|
||||
warpgatePattern.test(instructions) ||
|
||||
promptTexts.some((p) => warpgatePattern.test(p));
|
||||
|
||||
if (isWarpgate) {
|
||||
this.handleWarpgateAuth(name, instructions, promptTexts, finish);
|
||||
return;
|
||||
}
|
||||
|
||||
// JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push,
|
||||
// or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to
|
||||
// send Push request:"). Checked before the TOTP regex because the menu
|
||||
// prompt's own text ("...or [2] TOTP:") would otherwise match it and get
|
||||
// misrouted into the numeric-code flow.
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt = promptTexts.some((p) => pushPromptPattern.test(p));
|
||||
|
||||
if (isPushPrompt) {
|
||||
sshLogger.info("Push/menu MFA prompt detected", {
|
||||
operation: "ssh_keyboard_interactive_push",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
this.handleTotpAuth(
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
finish,
|
||||
resolvedCredentials,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
}
|
||||
|
||||
private handleWarpgateAuth(
|
||||
name: string,
|
||||
instructions: string,
|
||||
promptTexts: string[],
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
|
||||
|
||||
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
|
||||
const keyMatch = fullText.match(
|
||||
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
|
||||
);
|
||||
|
||||
if (urlMatch) {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
|
||||
this.context.warpgateAuthPromptSent = true;
|
||||
|
||||
this.sendLog("auth", "info", "Warpgate authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_required",
|
||||
url: urlMatch[0],
|
||||
securityKey: keyMatch ? keyMatch[1] : "N/A",
|
||||
instructions: instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.warpgateAuthTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
sshLogger.warn("Warpgate authentication timeout", {
|
||||
operation: "warpgate_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Warpgate authentication timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 300000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTotpAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
totpPromptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
authLogger.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_retry",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||
const totpCode = (totpResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
sshLogger.warn("TOTP prompt timeout", {
|
||||
operation: "totp_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
authLogger.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handlePasswordAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
|
||||
// Warpgate sends a password prompt before its browser-verification round; we must
|
||||
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
|
||||
if (hostConfig?.useWarpgate) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
|
||||
// that don't say "password" (e.g. "Passcode or option (1-N):").
|
||||
const firstUnansweredIndex = prompts.findIndex((p) => {
|
||||
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (firstUnansweredIndex !== -1) {
|
||||
if (this.context.keyboardInteractiveResponded) {
|
||||
return;
|
||||
}
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
const promptIndex =
|
||||
passwordPromptIndex !== -1 && !hasStoredPassword
|
||||
? passwordPromptIndex
|
||||
: firstUnansweredIndex;
|
||||
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt);
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === promptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
isPushPrompt ? 300000 : 180000,
|
||||
);
|
||||
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage,
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
|
||||
if (this.context.warpgateAuthTimeout) {
|
||||
clearTimeout(this.context.warpgateAuthTimeout);
|
||||
this.context.warpgateAuthTimeout = null;
|
||||
}
|
||||
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* One-line hand-off from any hosts feature to the automations engine for a
|
||||
* generic named event (not tied to metrics polling).
|
||||
*
|
||||
* Fire-and-forget and imported lazily: a failure in the automations layer
|
||||
* must never disturb the caller, and a static import would create a cycle
|
||||
* (automations reads repositories, which several hosts modules also pull in).
|
||||
*
|
||||
* PLUGIN-EVENT: this whole module is the "publish an internal event" half of
|
||||
* the phase-2 ctx.events bus. Once that exists, callers should emit onto
|
||||
* ctx.events instead of calling this directly, and the automations engine
|
||||
* subscribes there rather than being imported ad hoc.
|
||||
*/
|
||||
|
||||
export function notifyAutomationInternalEvent(
|
||||
event: string,
|
||||
userId: string,
|
||||
hostId?: number,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!userId) return;
|
||||
import("../automations/triggers.js")
|
||||
.then((triggers) =>
|
||||
triggers.onInternalEvent({ event, userId, hostId, details }),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Duplex } from "node:stream";
|
||||
import type { RawData, WebSocket } from "ws";
|
||||
|
||||
export function waitForWebSocketOpen(
|
||||
socket: WebSocket,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("open", onOpen);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
reject(new Error("Cloudflare tunnel timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
socket.once("open", onOpen);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
export function createWebSocketDuplex(socket: WebSocket): Duplex {
|
||||
const duplex = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
try {
|
||||
socket.send(chunk, (error) => callback(error || undefined));
|
||||
} catch (error) {
|
||||
callback(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
},
|
||||
destroy(error, callback) {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
callback(error);
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = (data: RawData) => duplex.push(data);
|
||||
const onClose = () => duplex.destroy();
|
||||
const onError = () => duplex.destroy();
|
||||
const cleanup = () => {
|
||||
socket.off("message", onMessage);
|
||||
socket.off("close", onClose);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
|
||||
socket.on("message", onMessage);
|
||||
socket.on("close", onClose);
|
||||
socket.on("error", onError);
|
||||
return duplex;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
const WINDOW_MS = 60_000;
|
||||
const MAX_ATTEMPTS = 60;
|
||||
const attempts = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
export function isCollabGuestRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||
attempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of attempts) {
|
||||
if (now - entry.windowStart > WINDOW_MS) attempts.delete(ip);
|
||||
}
|
||||
}, 5 * WINDOW_MS).unref();
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { collabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||
|
||||
export interface CollabRoomClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room
|
||||
* events and presence. The terminal WS server feeds subscribe/unsubscribe.
|
||||
*/
|
||||
class CollabRoomHub {
|
||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||
|
||||
constructor() {
|
||||
collabRuntimeStore.onEvent((roomId, message) => {
|
||||
if (
|
||||
"type" in message &&
|
||||
typeof message.type === "string" &&
|
||||
message.type.startsWith("collab_internal_")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLocal(roomId, message);
|
||||
});
|
||||
setInterval(() => {
|
||||
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
|
||||
}, PRESENCE_HEARTBEAT_MS).unref();
|
||||
}
|
||||
|
||||
subscribe(roomId: string, client: CollabRoomClient): void {
|
||||
let clients = this.rooms.get(roomId);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
this.rooms.set(roomId, clients);
|
||||
}
|
||||
for (const existing of clients) {
|
||||
if (existing.ws === client.ws) return;
|
||||
}
|
||||
clients.add(client);
|
||||
void this.refreshPresence(roomId);
|
||||
}
|
||||
|
||||
/** Drops the socket from one room, or from every room when roomId is omitted. */
|
||||
unsubscribe(ws: WebSocket, roomId?: string): void {
|
||||
for (const [id, clients] of this.rooms) {
|
||||
if (roomId && id !== roomId) continue;
|
||||
let removed = false;
|
||||
for (const client of clients) {
|
||||
if (client.ws === ws) {
|
||||
clients.delete(client);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (clients.size === 0) this.rooms.delete(id);
|
||||
if (removed) void this.refreshPresence(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(roomId: string, message: object): void {
|
||||
this.broadcastLocal(roomId, message);
|
||||
void collabRuntimeStore.publish(roomId, message);
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
return collabRuntimeStore.onlineUsers(
|
||||
roomId,
|
||||
this.localOnlineUsers(roomId),
|
||||
);
|
||||
}
|
||||
|
||||
private broadcastLocal(roomId: string, message: object): void {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
if (client.ws.readyState !== client.ws.OPEN) continue;
|
||||
try {
|
||||
client.ws.send(payload);
|
||||
} catch {
|
||||
/* keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private localOnlineUsers(
|
||||
roomId: string,
|
||||
): Array<{ userId: string; username: string }> {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const client of clients) {
|
||||
seen.set(client.userId, client.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
private async refreshPresence(roomId: string): Promise<void> {
|
||||
const localUsers = this.localOnlineUsers(roomId);
|
||||
await collabRuntimeStore.updatePresence(roomId, localUsers);
|
||||
this.broadcast(roomId, {
|
||||
type: "collab_online",
|
||||
roomId,
|
||||
users: await this.onlineUsers(roomId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const collabRoomHub = new CollabRoomHub();
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Whether a user may join a room-stage share: the share must be the stage of
|
||||
* a live room they are a member of. Membership is the authorization - room
|
||||
* stages are read-only and never expose host credentials or config.
|
||||
*/
|
||||
export async function canJoinRoomStageShare(
|
||||
shareId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
const room = await repository.findByStageShareId(shareId);
|
||||
if (!room) return false;
|
||||
return !!(await repository.findMember(room.id, userId));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,286 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { createClient } from "redis";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
interface PresenceEntry {
|
||||
instanceId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface EventEnvelope {
|
||||
source: string;
|
||||
roomId: string;
|
||||
message: object;
|
||||
}
|
||||
|
||||
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
|
||||
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
|
||||
const PRESENCE_TTL_MS = 45_000;
|
||||
const STATE_TTL_SECONDS = 12 * 60 * 60;
|
||||
const CONNECT_RETRY_MS = 15_000;
|
||||
|
||||
export class CollabRuntimeStore {
|
||||
private readonly instanceId = crypto.randomUUID();
|
||||
private publisher: ReturnType<typeof createClient> | null = null;
|
||||
private subscriber: ReturnType<typeof createClient> | null = null;
|
||||
private connecting: Promise<boolean> | null = null;
|
||||
private nextConnectAttempt = 0;
|
||||
private eventListeners = new Set<(roomId: string, message: object) => void>();
|
||||
private localControllers = new Map<string, string>();
|
||||
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
|
||||
private presenceMembers = new Map<string, Set<string>>();
|
||||
|
||||
onEvent(listener: (roomId: string, message: object) => void): void {
|
||||
this.eventListeners.add(listener);
|
||||
void this.ensureConnected();
|
||||
}
|
||||
|
||||
async publish(roomId: string, message: object): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.publish(
|
||||
EVENT_CHANNEL,
|
||||
JSON.stringify({ source: this.instanceId, roomId, message }),
|
||||
)
|
||||
.catch((error) => this.logRedisFailure("publish", error));
|
||||
}
|
||||
|
||||
async getController(roomId: string): Promise<string | null> {
|
||||
if (await this.ensureConnected()) {
|
||||
const value = await this.publisher
|
||||
?.get(`${KEY_PREFIX}:controller:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("get_controller", error);
|
||||
return undefined;
|
||||
});
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return this.localControllers.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
async setController(roomId: string, userId: string | null): Promise<void> {
|
||||
if (userId) this.localControllers.set(roomId, userId);
|
||||
else this.localControllers.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:controller:${roomId}`;
|
||||
const operation = userId
|
||||
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
|
||||
: this.publisher.del(key);
|
||||
await operation.catch((error) =>
|
||||
this.logRedisFailure("set_controller", error),
|
||||
);
|
||||
}
|
||||
|
||||
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
|
||||
if (await this.ensureConnected()) {
|
||||
const values = await this.publisher
|
||||
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("list_requests", error);
|
||||
return null;
|
||||
});
|
||||
if (values) {
|
||||
return values
|
||||
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
|
||||
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
||||
}
|
||||
}
|
||||
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
|
||||
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
|
||||
);
|
||||
}
|
||||
|
||||
async upsertRequest(
|
||||
roomId: string,
|
||||
request: CollabControlRequest,
|
||||
): Promise<void> {
|
||||
let requests = this.localRequests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
this.localRequests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:requests:${roomId}`;
|
||||
await this.publisher
|
||||
.multi()
|
||||
.hSet(key, request.userId, JSON.stringify(request))
|
||||
.expire(key, STATE_TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((error) => this.logRedisFailure("upsert_request", error));
|
||||
}
|
||||
|
||||
async removeRequest(roomId: string, userId: string): Promise<void> {
|
||||
const requests = this.localRequests.get(roomId);
|
||||
requests?.delete(userId);
|
||||
if (requests?.size === 0) this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
|
||||
.catch((error) => this.logRedisFailure("remove_request", error));
|
||||
}
|
||||
|
||||
async clearRequests(roomId: string): Promise<void> {
|
||||
this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.del(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => this.logRedisFailure("clear_requests", error));
|
||||
}
|
||||
|
||||
async updatePresence(
|
||||
roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
|
||||
const expiresAt = Date.now() + PRESENCE_TTL_MS;
|
||||
const current = new Set(
|
||||
users.map((user) =>
|
||||
JSON.stringify({
|
||||
instanceId: this.instanceId,
|
||||
...user,
|
||||
} satisfies PresenceEntry),
|
||||
),
|
||||
);
|
||||
const transaction = this.publisher.multi();
|
||||
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
|
||||
for (const member of current) {
|
||||
transaction.zAdd(key, { score: expiresAt, value: member });
|
||||
}
|
||||
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
|
||||
await transaction
|
||||
.exec()
|
||||
.then(() => {
|
||||
if (current.size > 0) this.presenceMembers.set(roomId, current);
|
||||
else this.presenceMembers.delete(roomId);
|
||||
})
|
||||
.catch((error) => this.logRedisFailure("update_presence", error));
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
localUsers: Array<{ userId: string; username: string }>,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const now = Date.now();
|
||||
const transaction = this.publisher
|
||||
.multi()
|
||||
.zRemRangeByScore(key, 0, now)
|
||||
.zRangeByScore(key, now + 1, "+inf");
|
||||
const result = await transaction.exec().catch((error) => {
|
||||
this.logRedisFailure("online_users", error);
|
||||
return null;
|
||||
});
|
||||
const members = result?.[1] as string[] | undefined;
|
||||
if (!members) return localUsers;
|
||||
const seen = new Map<string, string>();
|
||||
for (const member of members) {
|
||||
const entry = this.parse<PresenceEntry>(member);
|
||||
if (entry) seen.set(entry.userId, entry.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
|
||||
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL?.trim();
|
||||
if (!url) return false;
|
||||
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
|
||||
if (Date.now() < this.nextConnectAttempt) return false;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = this.connect(url).finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async connect(url: string): Promise<boolean> {
|
||||
try {
|
||||
this.publisher = createClient({
|
||||
url,
|
||||
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||
});
|
||||
this.subscriber = this.publisher.duplicate();
|
||||
this.publisher.on("error", (error) =>
|
||||
this.logRedisFailure("client", error),
|
||||
);
|
||||
this.subscriber.on("error", (error) =>
|
||||
this.logRedisFailure("subscriber", error),
|
||||
);
|
||||
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
||||
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
|
||||
const envelope = this.parse<EventEnvelope>(raw);
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope.source !== "string" ||
|
||||
typeof envelope.roomId !== "string" ||
|
||||
!envelope.message ||
|
||||
typeof envelope.message !== "object" ||
|
||||
envelope.source === this.instanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(envelope.roomId, envelope.message);
|
||||
} catch (error) {
|
||||
this.logRedisFailure("event_listener", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
sshLogger.info("Collaboration Redis runtime connected", {
|
||||
operation: "collab_redis_connected",
|
||||
});
|
||||
this.nextConnectAttempt = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||
this.logRedisFailure("connect", error);
|
||||
await Promise.allSettled([
|
||||
this.publisher?.disconnect(),
|
||||
this.subscriber?.disconnect(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parse<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private logRedisFailure(operation: string, error: unknown): void {
|
||||
sshLogger.warn(
|
||||
"Collaboration Redis runtime unavailable; using local state",
|
||||
{
|
||||
operation: `collab_redis_${operation}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const collabRuntimeStore = new CollabRuntimeStore();
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Who besides the presenter may drive the current stage, per room.
|
||||
*
|
||||
* Redis-backed when REDIS_URL is configured, with a local fallback for
|
||||
* single-instance deployments. Every stage switch clears it.
|
||||
*/
|
||||
import { collabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
export function getStageController(roomId: string): Promise<string | null> {
|
||||
return collabRuntimeStore.getController(roomId);
|
||||
}
|
||||
|
||||
export function setStageController(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
return collabRuntimeStore.setController(roomId, userId);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Every SSH auth type the server knows how to connect with.
|
||||
*
|
||||
* Core registers password, key, credential, agent and none. The rest come from
|
||||
* plugins through ctx.auth.registerSshAuthProvider. A type with no provider fails the
|
||||
* connect with a message naming the plugin that would provide it.
|
||||
*/
|
||||
|
||||
import type {
|
||||
KeyboardInteractiveInterceptor,
|
||||
SshAuthProvider,
|
||||
} from "./types.js";
|
||||
|
||||
const providers = new Map<string, SshAuthProvider>();
|
||||
const interceptors = new Map<string, KeyboardInteractiveInterceptor>();
|
||||
|
||||
export interface SshAuthTypeOwner {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plugin declares which auth type, from every manifest on disk
|
||||
* including disabled ones. Set by the plugin runtime.
|
||||
*/
|
||||
let ownerSource: () => Array<SshAuthTypeOwner & { type: string }> = () => [];
|
||||
|
||||
export function setSshAuthTypeOwnerSource(
|
||||
source: () => Array<SshAuthTypeOwner & { type: string }>,
|
||||
): void {
|
||||
ownerSource = source;
|
||||
}
|
||||
|
||||
export function listSshAuthTypeOwners(): Array<
|
||||
SshAuthTypeOwner & { type: string }
|
||||
> {
|
||||
return ownerSource();
|
||||
}
|
||||
|
||||
function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
|
||||
const owner = ownerSource().find((entry) => entry.type === type);
|
||||
return owner
|
||||
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
|
||||
: null;
|
||||
}
|
||||
|
||||
export class SshAuthProviderMissingError extends Error {
|
||||
readonly code = "SSH_AUTH_PROVIDER_MISSING";
|
||||
constructor(
|
||||
readonly authType: string,
|
||||
readonly owner: SshAuthTypeOwner | null,
|
||||
) {
|
||||
super(
|
||||
owner
|
||||
? `This host uses ${authType}, which needs the ${owner.pluginName} plugin`
|
||||
: `This host uses ${authType}, which no enabled plugin provides`,
|
||||
);
|
||||
this.name = "SshAuthProviderMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSshAuthProvider(provider: SshAuthProvider): () => void {
|
||||
const existing = providers.get(provider.type);
|
||||
if (existing && existing.pluginId !== provider.pluginId) {
|
||||
throw new Error(
|
||||
`SSH auth type "${provider.type}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
providers.set(provider.type, provider);
|
||||
return () => {
|
||||
if (providers.get(provider.type) === provider) {
|
||||
providers.delete(provider.type);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSshAuthProvider(type: string): SshAuthProvider | undefined {
|
||||
return providers.get(type);
|
||||
}
|
||||
|
||||
/** Throws SshAuthProviderMissingError when nothing provides the type. */
|
||||
export function requireSshAuthProvider(type: string): SshAuthProvider {
|
||||
const provider = providers.get(type);
|
||||
if (!provider) {
|
||||
throw new SshAuthProviderMissingError(type, findSshAuthTypeOwner(type));
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function listSshAuthProviders(): SshAuthProvider[] {
|
||||
return [...providers.values()];
|
||||
}
|
||||
|
||||
/** Whether a registered provider works for a host that is never saved. */
|
||||
export function isQuickConnectAuthType(type: string): boolean {
|
||||
return providers.get(type)?.quickConnect === true;
|
||||
}
|
||||
|
||||
/** Types a stored credential may use. */
|
||||
export function listCredentialTypes(): string[] {
|
||||
return listSshAuthProviders()
|
||||
.filter((provider) => provider.credentialType)
|
||||
.map((provider) => provider.type);
|
||||
}
|
||||
|
||||
export function registerKeyboardInteractiveInterceptor(
|
||||
interceptor: KeyboardInteractiveInterceptor,
|
||||
): () => void {
|
||||
const existing = interceptors.get(interceptor.id);
|
||||
if (existing && existing.pluginId !== interceptor.pluginId) {
|
||||
throw new Error(
|
||||
`Keyboard-interactive handler "${interceptor.id}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
interceptors.set(interceptor.id, interceptor);
|
||||
return () => {
|
||||
if (interceptors.get(interceptor.id) === interceptor) {
|
||||
interceptors.delete(interceptor.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function listKeyboardInteractiveInterceptors(): KeyboardInteractiveInterceptor[] {
|
||||
return [...interceptors.values()];
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetSshAuthRegistryForTests(): void {
|
||||
providers.clear();
|
||||
interceptors.clear();
|
||||
ownerSource = () => [];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { resolveSshKeepalive } from "../ssh-keepalive.js";
|
||||
import {
|
||||
SshAuthProviderMissingError,
|
||||
requireSshAuthProvider,
|
||||
} from "./auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
SshAuthLog,
|
||||
SshAuthOutcome,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
SshConnectProfile,
|
||||
SshConnectPurpose,
|
||||
} from "./types.js";
|
||||
|
||||
interface PurposeDefaults {
|
||||
keepaliveIntervalMs: number;
|
||||
keepaliveCountMax: number;
|
||||
/** Let the host's sshOptions.keepalive* override the defaults. */
|
||||
hostKeepalive: boolean;
|
||||
readyTimeout: number;
|
||||
/** Socket idle timeout, when the transport set one. */
|
||||
socketTimeout?: number;
|
||||
env: Record<string, string> | null;
|
||||
}
|
||||
|
||||
const TERMINAL_ENV: Record<string, string> = {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
};
|
||||
|
||||
const BASIC_ENV: Record<string, string> = {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
const PROFILE_DEFAULTS: Record<SshConnectProfile, PurposeDefaults> = {
|
||||
terminal: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 120000,
|
||||
socketTimeout: 120000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
session: {
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
stream: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
forward: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
background: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
jump: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
};
|
||||
|
||||
export function getProfileDefaults(
|
||||
profile: SshConnectProfile = "background",
|
||||
): PurposeDefaults {
|
||||
return PROFILE_DEFAULTS[profile];
|
||||
}
|
||||
|
||||
export interface BuildConnectConfigOptions {
|
||||
/** Acting user: host key trust, certificates and tokens are per user. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: SshConnectProfile;
|
||||
client: Client;
|
||||
/** Server-side host id when it differs from host.id (sync-id lookups). */
|
||||
serverHostId?: number;
|
||||
/** Terminal socket for the changed-host-key prompt. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
interactive?: boolean;
|
||||
log?: SshAuthLog;
|
||||
/** Applied after the purpose defaults and before auth is prepared. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
}
|
||||
|
||||
export interface BuiltConnectConfig {
|
||||
config: MutableConnectConfig;
|
||||
provider: SshAuthProvider | null;
|
||||
outcome: SshAuthOutcome;
|
||||
env: SshAuthEnv;
|
||||
}
|
||||
|
||||
const noopLog: SshAuthLog = () => {};
|
||||
|
||||
function stripIpv6Brackets(ip: string): string {
|
||||
return ip?.replace(/^\[|\]$/g, "") || ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ssh2 config for one host: base settings for the purpose, the
|
||||
* host verifier, then the auth provider's prepare step. Never connects.
|
||||
*/
|
||||
export async function buildConnectConfig(
|
||||
host: SshConnectHost,
|
||||
options: BuildConnectConfigOptions,
|
||||
): Promise<BuiltConnectConfig> {
|
||||
ensureCoreSshAuthProviders();
|
||||
|
||||
const profile = options.profile ?? "background";
|
||||
const defaults = PROFILE_DEFAULTS[profile];
|
||||
const authType = host.authType || "none";
|
||||
const serverHostId = options.serverHostId ?? host.id;
|
||||
const isJumpHost = profile === "jump";
|
||||
const sshOptions = host.sshOptions ?? undefined;
|
||||
|
||||
const keepalive = defaults.hostKeepalive
|
||||
? resolveSshKeepalive(
|
||||
sshOptions?.keepaliveInterval,
|
||||
sshOptions?.keepaliveCountMax,
|
||||
defaults.keepaliveIntervalMs,
|
||||
defaults.keepaliveCountMax,
|
||||
)
|
||||
: {
|
||||
keepaliveInterval: defaults.keepaliveIntervalMs,
|
||||
keepaliveCountMax: defaults.keepaliveCountMax,
|
||||
};
|
||||
|
||||
const preloaded = serverHostId
|
||||
? await SSHHostKeyVerifier.preloadHostData(serverHostId)
|
||||
: null;
|
||||
|
||||
const config: MutableConnectConfig = {
|
||||
host: stripIpv6Brackets(host.ip),
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
// A jump hop with no auth never answered prompts before either.
|
||||
tryKeyboard: isJumpHost ? authType !== "none" : true,
|
||||
...keepalive,
|
||||
readyTimeout: defaults.readyTimeout,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
serverHostId,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
options.hostKeySocket ?? null,
|
||||
options.userId,
|
||||
isJumpHost,
|
||||
preloaded,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(sshOptions?.allowLegacyAlgorithms !== false),
|
||||
};
|
||||
if (defaults.env) config.env = { ...defaults.env };
|
||||
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
|
||||
|
||||
const env: SshAuthEnv = {
|
||||
client: options.client,
|
||||
userId: options.userId,
|
||||
hostId: serverHostId,
|
||||
purpose: options.purpose,
|
||||
interactive: options.interactive ?? false,
|
||||
log: options.log ?? noopLog,
|
||||
};
|
||||
|
||||
let provider: SshAuthProvider;
|
||||
try {
|
||||
provider = requireSshAuthProvider(authType);
|
||||
} catch (error) {
|
||||
if (error instanceof SshAuthProviderMissingError) {
|
||||
return {
|
||||
config,
|
||||
provider: null,
|
||||
env,
|
||||
outcome: {
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (provider.connectOptions) {
|
||||
Object.assign(config, provider.connectOptions(host, options.purpose));
|
||||
}
|
||||
if (options.overrides) {
|
||||
Object.assign(config, options.overrides);
|
||||
}
|
||||
|
||||
const outcome = await provider.prepare(config, host, env);
|
||||
return { config, provider, outcome, env };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* The SSH auth types core always provides: password, key, credential, agent
|
||||
* and none. Everything else is a plugin, through ctx.auth.
|
||||
*/
|
||||
|
||||
import ssh2 from "ssh2";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import {
|
||||
isPrivateKeyPassphraseError,
|
||||
preparePrivateKeyForSSH2,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import { registerSshAuthProvider } from "./auth-provider-registry.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
SshAuthOutcome,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "./types.js";
|
||||
|
||||
function keyText(key: SshConnectHost["key"]): string {
|
||||
if (!key) return "";
|
||||
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
|
||||
}
|
||||
|
||||
async function preparePassword(
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
): Promise<SshAuthOutcome> {
|
||||
if (!host.password) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "Password authentication requested but no password provided",
|
||||
};
|
||||
}
|
||||
// Forced keyboard-interactive answers the password prompt from the same
|
||||
// value instead of offering it up front.
|
||||
if (!host.forceKeyboardInteractive) {
|
||||
config.password = host.password;
|
||||
}
|
||||
env.log("info", "Using password authentication");
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
async function prepareKey(
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
): Promise<SshAuthOutcome> {
|
||||
const key = keyText(host.key);
|
||||
if (!key.trim()) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "SSH key authentication requested but no key provided",
|
||||
};
|
||||
}
|
||||
|
||||
const passphrase = host.keyPassword || undefined;
|
||||
try {
|
||||
config.privateKey = preparePrivateKeyForSSH2(key, passphrase);
|
||||
} catch (error) {
|
||||
if (isPrivateKeyPassphraseError(error)) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
code: "invalid-key",
|
||||
message: `SSH key format error: ${getErrorMessage(error, "Invalid private key format")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// A key that parses in our own helper can still be encrypted with no
|
||||
// passphrase given; ssh2 only notices at connect time otherwise.
|
||||
const parsed = ssh2.utils.parseKey(config.privateKey as Buffer, passphrase);
|
||||
if (parsed instanceof Error) {
|
||||
if (isPrivateKeyPassphraseError(parsed)) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
code: "invalid-key",
|
||||
message: `SSH key format error: ${parsed.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (passphrase) config.passphrase = passphrase;
|
||||
// Lets a server that wants key then password (or password only) still work.
|
||||
if (host.password) config.password = host.password;
|
||||
|
||||
const certPublicKey = host.certPublicKey;
|
||||
if (certPublicKey && certPublicKey.trim()) {
|
||||
try {
|
||||
const { applyCertificateAuth } =
|
||||
await import("@termix/plugin-sdk/ssh-certs");
|
||||
await applyCertificateAuth(
|
||||
config,
|
||||
env.client,
|
||||
{
|
||||
privateKey: config.privateKey as Buffer,
|
||||
certificate: certPublicKey,
|
||||
passphrase,
|
||||
},
|
||||
host.username,
|
||||
);
|
||||
env.log("info", "Using SSH key authentication with CA certificate");
|
||||
return { status: "ready" };
|
||||
} catch (certError) {
|
||||
env.log(
|
||||
"warning",
|
||||
`CA certificate setup failed, falling back to key only: ${getErrorMessage(certError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
env.log("info", "Using SSH key authentication");
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
const passwordProvider: SshAuthProvider = {
|
||||
type: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthPassword",
|
||||
quickConnect: true,
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: preparePassword,
|
||||
};
|
||||
|
||||
const keyProvider: SshAuthProvider = {
|
||||
type: "key",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthKey",
|
||||
quickConnect: true,
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: prepareKey,
|
||||
};
|
||||
|
||||
/**
|
||||
* The resolver expands a credential into key or password before connect, so
|
||||
* this only runs when that expansion found something odd, like an unresolved
|
||||
* reference. It behaves like whichever secret is present.
|
||||
*/
|
||||
const credentialProvider: SshAuthProvider = {
|
||||
type: "credential",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthCredential",
|
||||
quickConnect: true,
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
if (keyText(host.key).trim()) return prepareKey(config, host, env);
|
||||
if (host.password) return preparePassword(config, host, env);
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "The stored credential for this host could not be resolved",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const agentProvider: SshAuthProvider = {
|
||||
type: "agent",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthAgent",
|
||||
quickConnect: true,
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
const result = await applyAgentAuth(config, host.sshOptions);
|
||||
if ("error" in result) {
|
||||
return { status: "error", code: "failed", message: result.error };
|
||||
}
|
||||
env.log("info", `SSH agent configured (socket: ${result.socketPath})`);
|
||||
return { status: "ready" };
|
||||
},
|
||||
};
|
||||
|
||||
const noneProvider: SshAuthProvider = {
|
||||
type: "none",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthNone",
|
||||
quickConnect: true,
|
||||
// Keyboard-interactive only: a background poll would fire a live prompt
|
||||
// (a RADIUS or Duo push) with nobody there to answer it.
|
||||
supportsBackground: false,
|
||||
prepare: async (_config, _host, env) => {
|
||||
env.log("info", "Using keyboard-interactive authentication");
|
||||
return { status: "ready" };
|
||||
},
|
||||
};
|
||||
|
||||
const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
|
||||
passwordProvider,
|
||||
keyProvider,
|
||||
credentialProvider,
|
||||
agentProvider,
|
||||
noneProvider,
|
||||
];
|
||||
|
||||
export function registerBuiltinSshAuthProviders(): () => void {
|
||||
const disposers = BUILTIN_SSH_AUTH_PROVIDERS.map((provider) =>
|
||||
registerSshAuthProvider(provider),
|
||||
);
|
||||
return () => disposers.forEach((dispose) => dispose());
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* connectHost: the one way core and plugins open an SSH connection.
|
||||
*
|
||||
* Resolve the host (shared overrides, external secrets and owner decryption
|
||||
* all happen in resolveHostById), build the config through the host's auth
|
||||
* provider, open the transport, then connect with a keyboard-interactive
|
||||
* handler that either asks through a prompt channel or fills in the stored
|
||||
* password.
|
||||
*/
|
||||
|
||||
import { Client } from "ssh2";
|
||||
import { withConnection } from "../ssh-connection-pool.js";
|
||||
import { resolveHostById } from "../host-resolver.js";
|
||||
import { buildConnectConfig } from "./build-connect-config.js";
|
||||
import {
|
||||
createAutoKeyboardInteractiveHandler,
|
||||
createPromptKeyboardInteractiveHandler,
|
||||
type KeyboardInteractiveListener,
|
||||
} from "./keyboard-interactive.js";
|
||||
import { openSshTransport, type OpenTransportOptions } from "./transport.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthLog,
|
||||
SshAuthOutcome,
|
||||
SshConnectHost,
|
||||
SshConnectProfile,
|
||||
SshConnectPurpose,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
|
||||
export class SshConnectError extends Error {
|
||||
readonly code: string;
|
||||
constructor(readonly outcome: Exclude<SshAuthOutcome, { status: "ready" }>) {
|
||||
super(outcome.message);
|
||||
this.name = "SshConnectError";
|
||||
this.code =
|
||||
outcome.status === "error"
|
||||
? outcome.code
|
||||
: outcome.status === "interaction-required"
|
||||
? `${outcome.interaction}-required`
|
||||
: "retry";
|
||||
}
|
||||
}
|
||||
|
||||
class SshHostNotFoundError extends Error {
|
||||
constructor(readonly hostId: number) {
|
||||
super("Host not found or access denied");
|
||||
this.name = "SshHostNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConnectHostOptions {
|
||||
/** Acting user. Access and credentials are resolved for them. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: SshConnectProfile;
|
||||
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
|
||||
prompt?: SshPromptChannel;
|
||||
/** Replaces the keyboard-interactive handler entirely. */
|
||||
keyboardInteractive?: KeyboardInteractiveListener;
|
||||
/** Config fields to force, e.g. a longer readyTimeout. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
/** Overall timeout for reaching "ready". Defaults to 30s. */
|
||||
timeoutMs?: number;
|
||||
/** Use this client instead of a new one. */
|
||||
client?: Client;
|
||||
transport?: OpenTransportOptions;
|
||||
/**
|
||||
* An already-open stream to the host (a forwardOut channel through another
|
||||
* host). Skips openSshTransport entirely.
|
||||
*/
|
||||
sock?: MutableConnectConfig["sock"];
|
||||
log?: SshAuthLog;
|
||||
}
|
||||
|
||||
export interface SshConnection {
|
||||
client: Client;
|
||||
jumpClient: Client | null;
|
||||
host: SshConnectHost;
|
||||
config: MutableConnectConfig;
|
||||
/** Ends the connection and its jump chain. Safe to call twice. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export async function resolveConnectHost(
|
||||
target: number | SshConnectHost,
|
||||
userId: string,
|
||||
): Promise<SshConnectHost> {
|
||||
if (typeof target !== "number") return target;
|
||||
const host = await resolveHostById(target, userId);
|
||||
if (!host) throw new SshHostNotFoundError(target);
|
||||
return host as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
export async function connectHost(
|
||||
target: number | SshConnectHost,
|
||||
options: ConnectHostOptions,
|
||||
): Promise<SshConnection> {
|
||||
const host = await resolveConnectHost(target, options.userId);
|
||||
const client = options.client ?? new Client();
|
||||
|
||||
const { config, provider, outcome, env } = await buildConnectConfig(host, {
|
||||
userId: options.userId,
|
||||
purpose: options.purpose,
|
||||
profile: options.profile,
|
||||
client,
|
||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||
interactive: !!options.prompt,
|
||||
log: options.log,
|
||||
overrides: options.overrides,
|
||||
});
|
||||
if (outcome.status !== "ready") {
|
||||
throw new SshConnectError(outcome);
|
||||
}
|
||||
|
||||
let jumpClient: Client | null = null;
|
||||
if (options.sock) {
|
||||
config.sock = options.sock;
|
||||
} else {
|
||||
({ jumpClient } = await openSshTransport(host, config, {
|
||||
log: options.log,
|
||||
prompt: options.prompt,
|
||||
...options.transport,
|
||||
}));
|
||||
}
|
||||
|
||||
const keyboardInteractive =
|
||||
options.keyboardInteractive ??
|
||||
(options.prompt
|
||||
? createPromptKeyboardInteractiveHandler(host, options.prompt)
|
||||
: createAutoKeyboardInteractiveHandler(host));
|
||||
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
jumpClient?.end();
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
dispose();
|
||||
reject(new Error("SSH connection timeout"));
|
||||
}, options.timeoutMs ?? 30000);
|
||||
|
||||
client.on("ready", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient?.end();
|
||||
const authFailed = /All configured authentication methods failed/i.test(
|
||||
error.message,
|
||||
);
|
||||
if (authFailed && provider?.onAuthFailed) {
|
||||
try {
|
||||
provider.onAuthFailed(host, env, {
|
||||
error,
|
||||
retries: 0,
|
||||
canRetry: false,
|
||||
methodNotAvailable: false,
|
||||
});
|
||||
} catch {
|
||||
// cleanup hooks never mask the connect error
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
jumpClient?.end();
|
||||
});
|
||||
|
||||
client.on("keyboard-interactive", keyboardInteractive);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
return { client, jumpClient, host, config, dispose };
|
||||
}
|
||||
|
||||
/** Pool key for a host and purpose, matching the keys the transports used. */
|
||||
export function getConnectionPoolKey(
|
||||
prefix: string,
|
||||
host: SshConnectHost,
|
||||
): string {
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
||||
: "";
|
||||
return `${prefix}:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs fn on a pooled connection. The pool calls the factory only when it
|
||||
* has no idle connection for the key.
|
||||
*/
|
||||
export async function withHostConnection<T>(
|
||||
poolKey: string,
|
||||
target: number | SshConnectHost,
|
||||
options: ConnectHostOptions,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const host = await resolveConnectHost(target, options.userId);
|
||||
return withConnection(
|
||||
poolKey,
|
||||
async () => (await connectHost(host, options)).client,
|
||||
fn,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers core's providers once. Called lazily by the pipeline so tests and
|
||||
* scripts get the same set as a running server without a start-up hook.
|
||||
*/
|
||||
export function ensureCoreSshAuthProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerBuiltinSshAuthProviders();
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* One classifier for keyboard-interactive rounds, shared by every transport.
|
||||
*
|
||||
* It only decides what a round is. Answering is up to the transport: the
|
||||
* terminal asks over its socket, the file manager parks the connection, and
|
||||
* background work fills in the stored password and nothing else.
|
||||
*/
|
||||
|
||||
import { listKeyboardInteractiveInterceptors } from "./auth-provider-registry.js";
|
||||
import type {
|
||||
KeyboardInteractiveDecision,
|
||||
KeyboardInteractivePrompt,
|
||||
SshConnectHost,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
|
||||
const PASSWORD_PATTERN = /password/i;
|
||||
|
||||
// FortiToken asks for a code OR the word "push", so it needs a text field and
|
||||
// must not be treated as a confirm-only push. Checked before the push pattern
|
||||
// because its text also says "push notification".
|
||||
const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
|
||||
|
||||
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
|
||||
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
|
||||
const PUSH_PROMPT_PATTERN =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
|
||||
const TOTP_PATTERN =
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
|
||||
|
||||
export interface KeyboardInteractiveRound {
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
}
|
||||
|
||||
/** Stored password for every password prompt, empty for the rest. */
|
||||
function autoResponses(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p) =>
|
||||
PASSWORD_PATTERN.test(p.prompt) && password ? password : "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Puts the user's answer at one index and auto-fills the rest. */
|
||||
function responsesWithAnswer(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
answerIndex: number,
|
||||
answer: string,
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p, index) => {
|
||||
if (index === answerIndex) return answer;
|
||||
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
function shouldAutoAnswerPasswords(host: SshConnectHost): boolean {
|
||||
return listKeyboardInteractiveInterceptors().some(
|
||||
(interceptor) => interceptor.autoAnswerPasswords?.(host) === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyKeyboardInteractive(
|
||||
round: KeyboardInteractiveRound,
|
||||
host: SshConnectHost,
|
||||
): KeyboardInteractiveDecision {
|
||||
for (const interceptor of listKeyboardInteractiveInterceptors()) {
|
||||
const decision = interceptor.detect(round, host);
|
||||
if (decision) return decision;
|
||||
}
|
||||
|
||||
const { prompts } = round;
|
||||
const texts = prompts.map((p) => p.prompt);
|
||||
const isFortiToken = texts.some((p) => FORTI_TOKEN_PATTERN.test(p));
|
||||
const isPush =
|
||||
!isFortiToken && texts.some((p) => PUSH_PROMPT_PATTERN.test(p));
|
||||
|
||||
if (!isPush) {
|
||||
const totpIndex = prompts.findIndex((p) => TOTP_PATTERN.test(p.prompt));
|
||||
if (totpIndex !== -1) return { kind: "totp", promptIndex: totpIndex };
|
||||
}
|
||||
|
||||
if (shouldAutoAnswerPasswords(host)) {
|
||||
return { kind: "auto", responses: autoResponses(prompts, host.password) };
|
||||
}
|
||||
|
||||
const hasStoredPassword = !!host.password && host.authType !== "none";
|
||||
const passwordIndex = prompts.findIndex((p) =>
|
||||
PASSWORD_PATTERN.test(p.prompt),
|
||||
);
|
||||
// DUO/PAM challenges often don't say "password" at all.
|
||||
const firstUnanswered = prompts.findIndex(
|
||||
(p) => !(PASSWORD_PATTERN.test(p.prompt) && hasStoredPassword),
|
||||
);
|
||||
|
||||
if (firstUnanswered === -1) {
|
||||
return { kind: "auto", responses: autoResponses(prompts, host.password) };
|
||||
}
|
||||
|
||||
const promptIndex =
|
||||
passwordIndex !== -1 && !hasStoredPassword
|
||||
? passwordIndex
|
||||
: firstUnanswered;
|
||||
return {
|
||||
kind: "input",
|
||||
promptIndex,
|
||||
isPush: PUSH_PROMPT_PATTERN.test(prompts[promptIndex].prompt),
|
||||
};
|
||||
}
|
||||
|
||||
export type KeyboardInteractiveListener = (
|
||||
name: string,
|
||||
instructions: string,
|
||||
lang: string,
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
finish: (responses: string[]) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* The handler for connections with nobody to ask: the stored password goes
|
||||
* to password prompts, everything else gets an empty answer. That is what
|
||||
* every background path did before the pipeline.
|
||||
*/
|
||||
export function createAutoKeyboardInteractiveHandler(
|
||||
host: SshConnectHost,
|
||||
): KeyboardInteractiveListener {
|
||||
return (_name, _instructions, _lang, prompts, finish) => {
|
||||
finish(autoResponses(prompts, host.password));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler for connections with a prompt channel. One question per round;
|
||||
* a second TOTP round means the last code was wrong.
|
||||
*/
|
||||
export function createPromptKeyboardInteractiveHandler(
|
||||
host: SshConnectHost,
|
||||
channel: SshPromptChannel,
|
||||
): KeyboardInteractiveListener {
|
||||
let totpAsked = false;
|
||||
|
||||
return (name, instructions, _lang, prompts, finish) => {
|
||||
const decision = classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
|
||||
const ask = async (): Promise<string[]> => {
|
||||
switch (decision.kind) {
|
||||
case "auto":
|
||||
return decision.responses;
|
||||
case "browser": {
|
||||
await channel.ask(decision);
|
||||
return prompts.map(() => "");
|
||||
}
|
||||
case "totp": {
|
||||
const answer = await channel.ask({
|
||||
kind: "totp",
|
||||
prompt: prompts[decision.promptIndex].prompt,
|
||||
retry: totpAsked,
|
||||
});
|
||||
totpAsked = true;
|
||||
return responsesWithAnswer(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
(answer ?? "").trim(),
|
||||
host.password,
|
||||
);
|
||||
}
|
||||
case "input": {
|
||||
const prompt = prompts[decision.promptIndex];
|
||||
const answer = await channel.ask({
|
||||
kind: "input",
|
||||
prompt: prompt.prompt,
|
||||
echo: prompt.echo,
|
||||
isPush: decision.isPush,
|
||||
});
|
||||
return responsesWithAnswer(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
(answer ?? "").trim(),
|
||||
host.password,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ask().then(finish, () => finish(prompts.map(() => "")));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Which plugin resolves a "<scheme>://..." secret reference in a host's
|
||||
* secret fields ("op://vault/item/field" for 1Password Connect, and so on).
|
||||
*
|
||||
* Core registers nothing here: every scheme comes from a plugin through
|
||||
* ctx.credentials.registerSecretResolver. A reference whose scheme has no
|
||||
* resolver fails the connect with a message naming the plugin that would
|
||||
* provide it, the same shape as SshAuthProviderMissingError.
|
||||
*/
|
||||
|
||||
export interface SecretResolverOwner {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export type SecretResolveFn = (
|
||||
userId: string,
|
||||
reference: string,
|
||||
) => Promise<string>;
|
||||
|
||||
interface SecretResolverRegistration extends SecretResolverOwner {
|
||||
resolve: SecretResolveFn;
|
||||
}
|
||||
|
||||
const resolvers = new Map<string, SecretResolverRegistration>();
|
||||
|
||||
/**
|
||||
* Which plugin declares which secret scheme, from every manifest on disk
|
||||
* including disabled ones. Set by the plugin runtime, mirroring
|
||||
* setSshAuthTypeOwnerSource.
|
||||
*/
|
||||
let ownerSource: () => Array<
|
||||
SecretResolverOwner & { scheme: string }
|
||||
> = () => [];
|
||||
|
||||
export function setSecretResolverOwnerSource(
|
||||
source: () => Array<SecretResolverOwner & { scheme: string }>,
|
||||
): void {
|
||||
ownerSource = source;
|
||||
}
|
||||
|
||||
function findSchemeOwner(scheme: string): SecretResolverOwner | null {
|
||||
const owner = ownerSource().find((entry) => entry.scheme === scheme);
|
||||
return owner
|
||||
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
|
||||
: null;
|
||||
}
|
||||
|
||||
class SecretResolverMissingError extends Error {
|
||||
readonly code = "SECRET_RESOLVER_MISSING";
|
||||
constructor(
|
||||
readonly scheme: string,
|
||||
readonly owner: SecretResolverOwner | null,
|
||||
) {
|
||||
super(
|
||||
owner
|
||||
? `This host uses a "${scheme}://" secret reference, which needs the ${owner.pluginName} plugin`
|
||||
: `This host uses a "${scheme}://" secret reference, which no enabled plugin resolves`,
|
||||
);
|
||||
this.name = "SecretResolverMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSecretResolver(
|
||||
scheme: string,
|
||||
owner: SecretResolverOwner,
|
||||
resolve: SecretResolveFn,
|
||||
): () => void {
|
||||
const existing = resolvers.get(scheme);
|
||||
if (existing && existing.pluginId !== owner.pluginId) {
|
||||
throw new Error(
|
||||
`Secret scheme "${scheme}" is already resolved by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
const registration = { ...owner, resolve };
|
||||
resolvers.set(scheme, registration);
|
||||
return () => {
|
||||
if (resolvers.get(scheme) === registration) {
|
||||
resolvers.delete(scheme);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSecretResolver(
|
||||
scheme: string,
|
||||
): SecretResolverRegistration | undefined {
|
||||
return resolvers.get(scheme);
|
||||
}
|
||||
|
||||
export function requireSecretResolver(
|
||||
scheme: string,
|
||||
): SecretResolverRegistration {
|
||||
const resolver = resolvers.get(scheme);
|
||||
if (!resolver) {
|
||||
throw new SecretResolverMissingError(scheme, findSchemeOwner(scheme));
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetSecretResolverRegistryForTests(): void {
|
||||
resolvers.clear();
|
||||
ownerSource = () => [];
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||
* proxy, in that order. Sets config.sock and
|
||||
* returns the jump client so the caller can close it with the connection.
|
||||
*/
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { performPortKnocking } from "../terminal-auth-helpers.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthLog,
|
||||
SshConnectHost,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
|
||||
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
const chain = Array.isArray(host.socks5ProxyChain)
|
||||
? (host.socks5ProxyChain as ProxyNode[])
|
||||
: [];
|
||||
if (!host.useSocks5 || (!host.socks5Host && chain.length === 0)) return null;
|
||||
return {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host ?? undefined,
|
||||
socks5Port: host.socks5Port ?? undefined,
|
||||
socks5Username: host.socks5Username ?? undefined,
|
||||
socks5Password: host.socks5Password ?? undefined,
|
||||
socks5ProxyChain: chain,
|
||||
};
|
||||
}
|
||||
|
||||
class SshTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly stage: "jump-host" | "jump-forward" | "proxy",
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "SshTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenTransportOptions {
|
||||
prompt?: SshPromptChannel;
|
||||
/** Knock before connecting when the host has a sequence. Default true. */
|
||||
portKnock?: boolean;
|
||||
/** Resolve DNS up front for a direct connection. Default true. */
|
||||
resolveDns?: boolean;
|
||||
log?: SshAuthLog;
|
||||
}
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
jumpClient: Client,
|
||||
host: SshConnectHost,
|
||||
): Promise<NonNullable<MutableConnectConfig["sock"]>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
reject(
|
||||
new SshTransportError(
|
||||
"Failed to forward through jump host: " + err.message,
|
||||
"jump-forward",
|
||||
{ cause: err },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function openSshTransport(
|
||||
host: SshConnectHost,
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
host.portKnockSequence.length > 0
|
||||
) {
|
||||
try {
|
||||
await performPortKnocking(host.ip, host.portKnockSequence);
|
||||
} catch {
|
||||
logger.warn("Port knocking failed, attempting connection anyway", {
|
||||
operation: "port_knock",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let via: OpenedTransport["via"] = "direct";
|
||||
|
||||
const jumpUserId = host.userId || "";
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
host.jumpHosts,
|
||||
jumpUserId,
|
||||
options.prompt,
|
||||
);
|
||||
if (!jumpClient) {
|
||||
throw new SshTransportError(
|
||||
"Failed to connect through jump hosts",
|
||||
"jump-host",
|
||||
);
|
||||
}
|
||||
try {
|
||||
config.sock = await forwardThrough(jumpClient, host);
|
||||
} catch (error) {
|
||||
jumpClient.end();
|
||||
throw error;
|
||||
}
|
||||
return { jumpClient, via: "jump" };
|
||||
}
|
||||
|
||||
const proxyConfig = getHostSocks5Config(host);
|
||||
if (proxyConfig && via === "direct") {
|
||||
try {
|
||||
const proxySocket = await createSocks5Connection(
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
proxyConfig,
|
||||
);
|
||||
if (proxySocket) {
|
||||
config.sock = proxySocket;
|
||||
via = "proxy";
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SshTransportError(
|
||||
"Proxy connection failed: " + getErrorMessage(error),
|
||||
"proxy",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (via === "direct" && options.resolveDns !== false) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
return { jumpClient: null, via };
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { HostSshOptions } from "../ssh-options.js";
|
||||
|
||||
/**
|
||||
* What a connection is for, as a free label ("terminal", "metrics"). Auth
|
||||
* providers see it; core attaches no meaning to any value.
|
||||
*/
|
||||
export type SshConnectPurpose = string;
|
||||
|
||||
/**
|
||||
* Keepalive, timeout and environment defaults for a kind of connection.
|
||||
* terminal: an interactive shell. session: a long-lived browsing session.
|
||||
* stream: a long transfer or console. forward: port forwarding. background:
|
||||
* short exec work such as polling. jump: a hop in a jump chain (core only).
|
||||
*/
|
||||
export type SshConnectProfile =
|
||||
"terminal" | "session" | "stream" | "forward" | "background" | "jump";
|
||||
|
||||
/** The resolved host fields the pipeline reads. */
|
||||
export interface SshConnectHost {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
authType?: string | null;
|
||||
password?: string | null;
|
||||
key?: string | Buffer | null;
|
||||
keyPassword?: string | null;
|
||||
keyType?: string | null;
|
||||
certPublicKey?: string | null;
|
||||
/**
|
||||
* Host-scope plugin settings by plugin id, filled in by the resolver so
|
||||
* synchronous hooks (keyboard-interactive handlers) can read them.
|
||||
*/
|
||||
pluginSettings?: Record<string, Record<string, unknown>> | null;
|
||||
forceKeyboardInteractive?: boolean | null;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
useSocks5?: boolean | null;
|
||||
socks5Host?: string | null;
|
||||
socks5Port?: number | null;
|
||||
socks5Username?: string | null;
|
||||
socks5Password?: string | null;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: string;
|
||||
delay?: number;
|
||||
}> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type MutableConnectConfig = ConnectConfig & Record<string, unknown>;
|
||||
|
||||
export type SshAuthLog = (
|
||||
level: "info" | "warning" | "error",
|
||||
message: string,
|
||||
) => void;
|
||||
|
||||
export interface SshAuthEnv {
|
||||
/** The client about to connect; certificate auth patches it. */
|
||||
client: Client;
|
||||
/** The acting user. Certificates and tokens are cached per user. */
|
||||
userId: string;
|
||||
/** Server-side host id, used for per-host caches. */
|
||||
hostId: number;
|
||||
purpose: SshConnectPurpose;
|
||||
/** True when a person can answer prompts or finish a browser sign-in. */
|
||||
interactive: boolean;
|
||||
log: SshAuthLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* What preparing auth produced.
|
||||
*
|
||||
* `interaction-required` means a browser step has to happen first; the
|
||||
* transport turns `interaction` into its own message, for example
|
||||
* `opkssh_auth_required` over the terminal socket.
|
||||
*/
|
||||
export type SshAuthOutcome =
|
||||
| { status: "ready" }
|
||||
| {
|
||||
status: "interaction-required";
|
||||
interaction: string;
|
||||
message: string;
|
||||
/** Extra boolean the HTTP transports set in their 401 body. */
|
||||
flag?: string;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
code:
|
||||
| "missing-secret"
|
||||
| "invalid-key"
|
||||
| "passphrase-required"
|
||||
| "provider-missing"
|
||||
| "failed";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
/** Try once more with these config changes. Used by Tailscale. */
|
||||
status: "retry";
|
||||
patch: Partial<MutableConnectConfig>;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface SshAuthFailureContext {
|
||||
error: Error;
|
||||
/** How many retries this connection already made. */
|
||||
retries: number;
|
||||
/** A tunnelled socket cannot be reused for a retry. */
|
||||
canRetry: boolean;
|
||||
/** The server refused keyboard-interactive or none. */
|
||||
methodNotAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface SshInteractionRequest {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
/** Host row as stored, not resolved. */
|
||||
host: { name?: string | null; ip: string; username: string };
|
||||
socket: WebSocket;
|
||||
requestOrigin: string;
|
||||
/** Anything the client sent with the start message. */
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SshAuthField {
|
||||
key: string;
|
||||
type:
|
||||
| "boolean"
|
||||
| "string"
|
||||
| "number"
|
||||
| "select"
|
||||
| "secret"
|
||||
| "textarea"
|
||||
| "custom";
|
||||
labelKey?: string;
|
||||
descriptionKey?: string;
|
||||
placeholderKey?: string;
|
||||
default?: unknown;
|
||||
options?: Array<{ value: string; labelKey: string }>;
|
||||
component?: string;
|
||||
}
|
||||
|
||||
export interface SshAuthProvider {
|
||||
/** Stored in ssh_data.auth_type. */
|
||||
type: string;
|
||||
/** "core" for the built-ins, otherwise the registering plugin's id. */
|
||||
pluginId: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
/** Editor fields, rendered by core unless the frontend registered an editor. */
|
||||
fields?: SshAuthField[];
|
||||
/** Also offered as a stored credential type. */
|
||||
credentialType?: boolean;
|
||||
/** Needs a browser sign-in or a person at the keyboard. */
|
||||
needsUserInteraction?: boolean;
|
||||
/** Carries a secret, so a shared host needs one resolved for the recipient. */
|
||||
requiresSecret?: boolean;
|
||||
/**
|
||||
* Can connect with nobody watching, for polling and batch work. Defaults to
|
||||
* true; false for types that need a person or a fresh browser sign-in.
|
||||
*/
|
||||
supportsBackground?: boolean;
|
||||
/**
|
||||
* Works for a host that was never saved, so Quick Connect offers it.
|
||||
* Default false.
|
||||
*/
|
||||
quickConnect?: boolean;
|
||||
/** Overrides applied to the base config before prepare runs. */
|
||||
connectOptions?: (
|
||||
host: SshConnectHost,
|
||||
purpose: SshConnectPurpose,
|
||||
) => Partial<MutableConnectConfig>;
|
||||
/** The interaction name this provider's outcomes use, e.g. "browser-signin". */
|
||||
interaction?: string;
|
||||
/**
|
||||
* Starts the browser step behind an `interaction-required` outcome, for
|
||||
* transports that can show one (the terminal today).
|
||||
*/
|
||||
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
|
||||
/** Cancels a pending browser step, by request id or by host. */
|
||||
cancelInteraction?: (request: {
|
||||
userId: string;
|
||||
hostId?: number;
|
||||
requestId?: string;
|
||||
}) => void | Promise<void>;
|
||||
prepare: (
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
) => Promise<SshAuthOutcome>;
|
||||
/**
|
||||
* Called when the server rejected auth. Synchronous on purpose: transports
|
||||
* decide on a retry before the socket's close event arrives. Cache cleanup
|
||||
* inside runs in the background.
|
||||
*/
|
||||
onAuthFailed?: (
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
context: SshAuthFailureContext,
|
||||
) => SshAuthOutcome | undefined;
|
||||
/**
|
||||
* Claims an ssh2 "banner" event during the handshake, for a provider whose
|
||||
* server holds the connection open pending an out-of-band step (Tailscale
|
||||
* SSH check mode). A transport that can show this to a person sends
|
||||
* `<type>_check_required` / `<type>_check_completed` over its socket;
|
||||
* `details` is merged into that message. Returning nothing leaves the
|
||||
* banner unhandled.
|
||||
*/
|
||||
onBanner?: (
|
||||
banner: string,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
) => SshBannerDecision | undefined;
|
||||
}
|
||||
|
||||
export type SshBannerDecision =
|
||||
| {
|
||||
/** Hold the connect timeout open and wait for the out-of-band step. */
|
||||
action: "hold";
|
||||
/** How long to wait before failing the connection. */
|
||||
timeoutMs: number;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
/** The out-of-band step finished; resume the normal connect timeout. */
|
||||
action: "release";
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A round finished in a browser: open the URL, compare the code, continue.
|
||||
* `id` names the handler that claimed it; transports name their messages
|
||||
* after it.
|
||||
*/
|
||||
export interface BrowserSignInRound {
|
||||
kind: "browser";
|
||||
id: string;
|
||||
label: string;
|
||||
url: string | null;
|
||||
code: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
/** What to do with one keyboard-interactive round. */
|
||||
export type KeyboardInteractiveDecision =
|
||||
| { kind: "auto"; responses: string[] }
|
||||
| BrowserSignInRound
|
||||
| { kind: "totp"; promptIndex: number }
|
||||
| { kind: "input"; promptIndex: number; isPush: boolean };
|
||||
|
||||
/**
|
||||
* Interceptors run before the built-in classifier. A plugin that owns a
|
||||
* prompt style returns a decision; everyone else returns null.
|
||||
*/
|
||||
export interface KeyboardInteractiveInterceptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
detect: (
|
||||
round: {
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
},
|
||||
host: SshConnectHost,
|
||||
) => KeyboardInteractiveDecision | null;
|
||||
/** Answer password prompts silently instead of asking the user. */
|
||||
autoAnswerPasswords?: (host: SshConnectHost) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a transport asks a person something mid-connect.
|
||||
*
|
||||
* `ask` resolves with the typed answer, or null when the user gave up or the
|
||||
* transport has nobody to ask. The terminal answers over its WebSocket, the
|
||||
* file manager parks the connection and answers over HTTP.
|
||||
*/
|
||||
export interface SshPromptChannel {
|
||||
ask: (request: SshPromptRequest) => Promise<string | null>;
|
||||
/** Terminal socket used for the host key prompt, when there is one. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
}
|
||||
|
||||
export type SshPromptRequest =
|
||||
| { kind: "totp"; prompt: string; retry: boolean }
|
||||
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
|
||||
| BrowserSignInRound;
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
|
||||
|
||||
export function createConnectionLog(
|
||||
type: "info" | "success" | "warning" | "error",
|
||||
stage: ConnectionStage,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Omit<LogEntry, "id" | "timestamp"> {
|
||||
return {
|
||||
type,
|
||||
stage,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -34,16 +34,20 @@ export function pickResolvedPassword(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const USERNAME_PLACEHOLDER =
|
||||
/\$(?:external\.username|oidc\.preferred_username)/g;
|
||||
|
||||
/**
|
||||
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||
* not contain the placeholder or the user has no OIDC identifier.
|
||||
* Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`)
|
||||
* in an SSH username to the name the connecting user signed in with through
|
||||
* SSO or LDAP. Returns the username unchanged if it has no placeholder or the
|
||||
* user has no external sign-in.
|
||||
*/
|
||||
export async function expandOidcUsername(
|
||||
export async function expandExternalUsername(
|
||||
username: string | undefined,
|
||||
userId: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!username || !username.includes("$oidc.preferred_username")) {
|
||||
if (!username || !new RegExp(USERNAME_PLACEHOLDER.source).test(username)) {
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -51,31 +55,29 @@ export async function expandOidcUsername(
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
let oidcIdentifier = user?.oidcIdentifier;
|
||||
if (!oidcIdentifier) return username;
|
||||
let externalName = user?.oidcIdentifier;
|
||||
if (!externalName) return username;
|
||||
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier);
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(externalName);
|
||||
if (match) {
|
||||
// Make sure the SSO provider is actually LDAP, to prevent spoofing.
|
||||
const { createCurrentSsoProviderRepository } =
|
||||
// Only strip the prefix for a real LDAP identity, to prevent spoofing
|
||||
// through an SSO subject that happens to look like one.
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const claimedProviderId = Number(match[1]);
|
||||
const provider =
|
||||
user?.ssoProviderId != null
|
||||
? await createCurrentSsoProviderRepository().findById(
|
||||
user.ssoProviderId,
|
||||
)
|
||||
: null;
|
||||
|
||||
const identities =
|
||||
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
|
||||
if (
|
||||
provider?.type === "ldap" &&
|
||||
user?.ssoProviderId === claimedProviderId
|
||||
identities.some(
|
||||
(identity) =>
|
||||
identity.providerId === `ldap:${match[1]}` &&
|
||||
identity.subject === match[2],
|
||||
)
|
||||
) {
|
||||
oidcIdentifier = match[2];
|
||||
externalName = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||
return username.replace(USERNAME_PLACEHOLDER, () => externalName);
|
||||
} catch {
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Every key a host can inherit: core's own catalog plus each running
|
||||
* plugin's host fields, in one shape the resolver and materializer share.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginSettingsField,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import { coerceSettingValue } from "@termix/plugin-sdk/settings";
|
||||
import {
|
||||
CORE_HOST_DEFAULTS,
|
||||
CORE_NAMESPACE,
|
||||
HOST_DEFAULTS_LEVELS,
|
||||
joinDefaultKey,
|
||||
type HostDefaultsLevel,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import { declaredFields } from "../../plugins/settings.js";
|
||||
|
||||
export interface DefaultKeyInfo {
|
||||
namespace: string;
|
||||
key: string;
|
||||
fullKey: string;
|
||||
levels: readonly HostDefaultsLevel[];
|
||||
/** Undefined when nothing is inherited unless some level sets it. */
|
||||
builtin: unknown;
|
||||
normalize: (value: unknown) => unknown;
|
||||
personal: boolean;
|
||||
/** Plugin keys only. */
|
||||
field?: PluginSettingsField;
|
||||
}
|
||||
|
||||
export type DefaultsCatalog = Map<string, DefaultKeyInfo>;
|
||||
|
||||
/** Whether a plugin's host field can have a default. */
|
||||
export function isDefaultableField(field: PluginSettingsField): boolean {
|
||||
if (field.type === "secret") return false;
|
||||
if (field.type === "json" && field.secretKeys?.length) return false;
|
||||
return field.defaultable !== false;
|
||||
}
|
||||
|
||||
/** A plugin's host fields that can have a default, enable switch included. */
|
||||
export function defaultableHostFields(
|
||||
manifest: PluginManifest,
|
||||
): PluginSettingsField[] {
|
||||
return declaredFields(manifest, "host").filter(isDefaultableField);
|
||||
}
|
||||
|
||||
function isUnset(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === "";
|
||||
}
|
||||
|
||||
export function normalizePluginValue(
|
||||
field: PluginSettingsField,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
// A row with no value reads as the manifest default, so compare that way.
|
||||
if (isUnset(value)) return field.default ?? null;
|
||||
try {
|
||||
const coerced = coerceSettingValue(field, value);
|
||||
return isUnset(coerced) ? (field.default ?? null) : coerced;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function coreCatalogEntries(): DefaultKeyInfo[] {
|
||||
return CORE_HOST_DEFAULTS.map((entry) => ({
|
||||
namespace: CORE_NAMESPACE,
|
||||
key: entry.key,
|
||||
fullKey: joinDefaultKey(CORE_NAMESPACE, entry.key),
|
||||
levels: entry.levels,
|
||||
builtin: entry.builtin,
|
||||
normalize: entry.normalize,
|
||||
personal: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function pluginCatalogEntries(
|
||||
manifest: PluginManifest,
|
||||
): DefaultKeyInfo[] {
|
||||
return defaultableHostFields(manifest).map((field) => ({
|
||||
namespace: manifest.id,
|
||||
key: field.key,
|
||||
fullKey: joinDefaultKey(manifest.id, field.key),
|
||||
levels: field.defaultLevels?.length
|
||||
? field.defaultLevels
|
||||
: HOST_DEFAULTS_LEVELS,
|
||||
builtin: normalizePluginValue(field, field.default),
|
||||
normalize: (value: unknown) => normalizePluginValue(field, value),
|
||||
personal: field.personal === true,
|
||||
field,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCatalog(manifests: PluginManifest[]): DefaultsCatalog {
|
||||
const catalog: DefaultsCatalog = new Map();
|
||||
for (const entry of coreCatalogEntries()) catalog.set(entry.fullKey, entry);
|
||||
for (const manifest of manifests) {
|
||||
for (const entry of pluginCatalogEntries(manifest)) {
|
||||
catalog.set(entry.fullKey, entry);
|
||||
}
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/** Namespaces the catalog covers: core plus each plugin with a defaultable field. */
|
||||
export function catalogNamespaces(catalog: DefaultsCatalog): string[] {
|
||||
return [...new Set([...catalog.values()].map((entry) => entry.namespace))];
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Core host defaults in column form.
|
||||
*
|
||||
* A host row (or the column object a host route builds before its write) is
|
||||
* read into a default's value and written back from one. Both directions go
|
||||
* through the catalog's normalize, so a value compares the same however the
|
||||
* row spelled it.
|
||||
*/
|
||||
|
||||
import {
|
||||
findCoreHostDefault,
|
||||
normalizeAuthDefault,
|
||||
normalizeSocks5Default,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import { parseSshOptions } from "../ssh-options.js";
|
||||
|
||||
type Columns = Record<string, unknown>;
|
||||
|
||||
const SSH_OPTION_DEFAULTS = [
|
||||
"keepaliveInterval",
|
||||
"keepaliveCountMax",
|
||||
"allowLegacyAlgorithms",
|
||||
"agentForwarding",
|
||||
"environmentVariables",
|
||||
] as const;
|
||||
|
||||
function isSshOptionDefault(key: string): boolean {
|
||||
return (SSH_OPTION_DEFAULTS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
/** The value a core key has on a row, normalized. */
|
||||
export function readCoreDefault(
|
||||
key: string,
|
||||
row: Columns,
|
||||
storedSshOptions?: unknown,
|
||||
): unknown {
|
||||
const entry = findCoreHostDefault(key);
|
||||
if (!entry) return undefined;
|
||||
const sshOptions = parseSshOptions(
|
||||
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
|
||||
) as Record<string, unknown>;
|
||||
|
||||
switch (key) {
|
||||
case "username":
|
||||
return entry.normalize(row.username);
|
||||
case "sshPort":
|
||||
return entry.normalize(row.sshPort ?? row.port);
|
||||
case "auth":
|
||||
return normalizeAuthDefault({
|
||||
authType: row.authType,
|
||||
credentialId: row.credentialId,
|
||||
overrideCredentialUsername: asBoolean(row.overrideCredentialUsername),
|
||||
agentSocketPath: sshOptions.agentSocketPath,
|
||||
agentIdentity: sshOptions.agentIdentity,
|
||||
});
|
||||
case "forceKeyboardInteractive":
|
||||
return entry.normalize(row.forceKeyboardInteractive);
|
||||
case "socks5":
|
||||
return normalizeSocks5Default({
|
||||
useSocks5: asBoolean(row.useSocks5),
|
||||
socks5Host: row.socks5Host,
|
||||
socks5Port: row.socks5Port,
|
||||
socks5Username: row.socks5Username,
|
||||
socks5ProxyChain: row.socks5ProxyChain,
|
||||
});
|
||||
case "jumpHosts":
|
||||
case "portKnockSequence":
|
||||
case "statusCheckEnabled":
|
||||
case "statusCheckInterval":
|
||||
return entry.normalize(row[key]);
|
||||
default:
|
||||
if (isSshOptionDefault(key)) return entry.normalize(sshOptions[key]);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The column patch that gives a row this value. `row` is read for what the
|
||||
* patch depends on: the connection type (an SSH host's port follows its SSH
|
||||
* port) and the SSH options the others share a column with.
|
||||
*/
|
||||
export function writeCoreDefault(
|
||||
key: string,
|
||||
value: unknown,
|
||||
row: Columns,
|
||||
storedSshOptions?: unknown,
|
||||
): Columns {
|
||||
const entry = findCoreHostDefault(key);
|
||||
if (!entry) return {};
|
||||
const normalized = entry.normalize(value);
|
||||
|
||||
if (isSshOptionDefault(key) || key === "auth") {
|
||||
const current = parseSshOptions(
|
||||
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
|
||||
) as Record<string, unknown>;
|
||||
if (key === "auth") {
|
||||
const auth = normalizeAuthDefault(value);
|
||||
if (!auth) return {};
|
||||
const next = { ...current };
|
||||
if (auth.authType === "agent") {
|
||||
next.agentSocketPath = auth.agentSocketPath;
|
||||
next.agentIdentity = auth.agentIdentity;
|
||||
} else {
|
||||
delete next.agentSocketPath;
|
||||
delete next.agentIdentity;
|
||||
}
|
||||
// A credential host may keep its own password next to the credential;
|
||||
// every other secretless type has no use for one.
|
||||
return {
|
||||
authType: auth.authType,
|
||||
credentialId: auth.credentialId,
|
||||
overrideCredentialUsername: auth.overrideCredentialUsername,
|
||||
sshOptions: JSON.stringify(next),
|
||||
...(auth.authType === "credential" ? {} : { password: null }),
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
};
|
||||
}
|
||||
return { sshOptions: JSON.stringify({ ...current, [key]: normalized }) };
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "username":
|
||||
return normalized === null ? {} : { username: normalized };
|
||||
case "sshPort": {
|
||||
const patch: Columns = { sshPort: normalized };
|
||||
if ((row.connectionType ?? "ssh") === "ssh") patch.port = normalized;
|
||||
return patch;
|
||||
}
|
||||
case "forceKeyboardInteractive":
|
||||
return { forceKeyboardInteractive: normalized ? "true" : "false" };
|
||||
case "socks5": {
|
||||
const socks = normalizeSocks5Default(value);
|
||||
return {
|
||||
useSocks5: socks.useSocks5,
|
||||
socks5Host: socks.socks5Host,
|
||||
socks5Port: socks.socks5Port,
|
||||
socks5Username: socks.socks5Username,
|
||||
socks5ProxyChain: socks.socks5ProxyChain
|
||||
? JSON.stringify(mergeChainPasswords(socks.socks5ProxyChain, row))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
case "jumpHosts":
|
||||
case "portKnockSequence":
|
||||
return {
|
||||
[key]:
|
||||
Array.isArray(normalized) && normalized.length > 0
|
||||
? JSON.stringify(normalized)
|
||||
: null,
|
||||
};
|
||||
case "statusCheckEnabled":
|
||||
return { statusCheckEnabled: !!normalized };
|
||||
case "statusCheckInterval":
|
||||
return { statusCheckInterval: normalized };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** A chain that follows a default keeps the passwords the host saved for it. */
|
||||
function mergeChainPasswords(chain: unknown[], row: Columns): unknown[] {
|
||||
let current: unknown = row.socks5ProxyChain;
|
||||
if (typeof current === "string") {
|
||||
try {
|
||||
current = JSON.parse(current);
|
||||
} catch {
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
const saved = Array.isArray(current) ? current : [];
|
||||
return chain.map((node, index) => {
|
||||
const own = saved[index] as Record<string, unknown> | undefined;
|
||||
const password = own?.password;
|
||||
return typeof password === "string" && password
|
||||
? { ...(node as Record<string, unknown>), password }
|
||||
: node;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { recompute } from "./recompute.js";
|
||||
|
||||
export { applyHostDefaultsToWrite } from "./write-rules.js";
|
||||
|
||||
/**
|
||||
* Brings one host's plugin settings in line with its defaults after a write,
|
||||
* and, when it moved folder or parent, every host of its owner (a sub-host
|
||||
* follows its parent's folder). A failure here never fails the write.
|
||||
*/
|
||||
export async function applyDefaultsAfterHostWrite(
|
||||
hostId: number,
|
||||
options: { moved?: boolean; ownerId?: string } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await recompute({ hostIds: [hostId] });
|
||||
if (options.moved && options.ownerId) {
|
||||
void recompute({ userIds: [options.ownerId] }).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not apply host defaults after a write", {
|
||||
operation: "host_defaults_after_write",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Loads host default levels from the database into the maps the resolver
|
||||
* reads. One pass covers any number of users.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { HostDefaultsRecord } from "../../database/repositories/host-defaults-repository.js";
|
||||
import { joinDefaultKey } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
folderChainPaths,
|
||||
type FolderLevel,
|
||||
type HostLevels,
|
||||
type LevelMap,
|
||||
} from "./resolve.js";
|
||||
|
||||
/**
|
||||
* On a desktop linked to a server, the server's admin defaults arrive over
|
||||
* sync and are kept here. They stand in for the desktop's own admin level so
|
||||
* both sides resolve a host the same way.
|
||||
*/
|
||||
export const REMOTE_ADMIN_DEFAULTS_KEY = "host_defaults_remote_admin";
|
||||
|
||||
export interface DefaultsIndex {
|
||||
admin: LevelMap;
|
||||
users: Map<string, LevelMap>;
|
||||
/** userId -> folder path -> the folder's level. */
|
||||
folders: Map<string, Map<string, FolderLevel>>;
|
||||
}
|
||||
|
||||
export function decodeDefaultValue(raw: string | null): unknown {
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeDefaultValue(value: unknown): string {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
async function remoteAdminLevel(): Promise<LevelMap | null> {
|
||||
try {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||
);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return new Map(Object.entries(parsed as Record<string, unknown>));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildIndex(
|
||||
rows: HostDefaultsRecord[],
|
||||
folders: Array<{ id: number; userId: string; name: string }>,
|
||||
): DefaultsIndex {
|
||||
const index: DefaultsIndex = {
|
||||
admin: new Map(),
|
||||
users: new Map(),
|
||||
folders: new Map(),
|
||||
};
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
for (const folder of folders) {
|
||||
let own = index.folders.get(folder.userId);
|
||||
if (!own) {
|
||||
own = new Map();
|
||||
index.folders.set(folder.userId, own);
|
||||
}
|
||||
own.set(folder.name, {
|
||||
folderId: folder.id,
|
||||
name: folder.name,
|
||||
values: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const fullKey = joinDefaultKey(row.namespace, row.key);
|
||||
const value = decodeDefaultValue(row.value);
|
||||
if (row.level === "admin") {
|
||||
index.admin.set(fullKey, value);
|
||||
} else if (row.level === "user" && row.userId) {
|
||||
let own = index.users.get(row.userId);
|
||||
if (!own) {
|
||||
own = new Map();
|
||||
index.users.set(row.userId, own);
|
||||
}
|
||||
own.set(fullKey, value);
|
||||
} else if (row.level === "folder" && row.folderId !== null) {
|
||||
const folder = folderById.get(row.folderId);
|
||||
if (!folder) continue;
|
||||
index.folders
|
||||
.get(folder.userId)
|
||||
?.get(folder.name)
|
||||
?.values.set(fullKey, value);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export async function loadDefaultsIndex(
|
||||
userIds: string[],
|
||||
): Promise<DefaultsIndex> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const [rows, folders, remoteAdmin] = await Promise.all([
|
||||
repository.listForUsers(userIds),
|
||||
repository.listFolders(userIds),
|
||||
remoteAdminLevel(),
|
||||
]);
|
||||
const index = buildIndex(rows, folders);
|
||||
if (remoteAdmin) index.admin = remoteAdmin;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function levelsForHost(
|
||||
index: DefaultsIndex,
|
||||
userId: string,
|
||||
folderPath: string | null,
|
||||
): HostLevels {
|
||||
const own = index.folders.get(userId);
|
||||
const folders: FolderLevel[] = [];
|
||||
for (const path of folderChainPaths(folderPath)) {
|
||||
const folder = own?.get(path);
|
||||
if (folder) folders.push(folder);
|
||||
}
|
||||
return {
|
||||
admin: index.admin,
|
||||
user: index.users.get(userId) ?? new Map(),
|
||||
folders,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Writes resolved defaults into the hosts that follow them.
|
||||
*
|
||||
* A host keeps its effective values in its own columns and plugin setting
|
||||
* rows, so every reader (connect, export, sync, a 2.8 downgrade) sees plain
|
||||
* values. What changes when a default does is this pass: every host that
|
||||
* inherits the key gets the new value written, and nothing else is touched.
|
||||
*
|
||||
* A namespace a host has never been classified for (a host from before host
|
||||
* defaults, from an older sync peer, or a plugin enabled later) is classified
|
||||
* first: a value equal to what resolves inherits, anything else is the
|
||||
* host's own.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||
import type { PluginSettingsRecord } from "../../database/repositories/plugin-settings-repository.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
defaultValuesEqual,
|
||||
parseDefaultOverrides,
|
||||
type DefaultOverrides,
|
||||
type HostDefaultsLevel,
|
||||
type ResolvedHostDefault,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import { hostSettingsPlugins } from "../../database/routes/host-plugin-settings.js";
|
||||
import { notifySettingChange } from "../../plugins/settings.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
buildCatalog,
|
||||
catalogNamespaces,
|
||||
type DefaultKeyInfo,
|
||||
type DefaultsCatalog,
|
||||
} from "./catalog.js";
|
||||
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
|
||||
import {
|
||||
loadDefaultsIndex,
|
||||
levelsForHost,
|
||||
type DefaultsIndex,
|
||||
} from "./levels.js";
|
||||
import {
|
||||
effectiveFolderPath,
|
||||
isOwnValue,
|
||||
resolveKey,
|
||||
withoutSelfJump,
|
||||
type HostLevels,
|
||||
type HostPlacement,
|
||||
} from "./resolve.js";
|
||||
|
||||
export interface HostDefaultsTarget {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
/** A level change that has not been saved yet, for a preview. */
|
||||
export interface LevelOverlay {
|
||||
level: HostDefaultsLevel;
|
||||
userId?: string;
|
||||
folderId?: number;
|
||||
set: Map<string, unknown>;
|
||||
unset: Set<string>;
|
||||
}
|
||||
|
||||
export interface MaterializeOptions {
|
||||
/** Only these keys are rewritten. Classification still covers every key. */
|
||||
keys?: Set<string>;
|
||||
dryRun?: boolean;
|
||||
overlay?: LevelOverlay;
|
||||
}
|
||||
|
||||
export interface MaterializeResult {
|
||||
changedHostIds: number[];
|
||||
}
|
||||
|
||||
export interface PluginWrite {
|
||||
hostId: number;
|
||||
pluginId: string;
|
||||
key: string;
|
||||
/** undefined deletes the row, so the host reads the manifest default. */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface HostPlan {
|
||||
columns: Record<string, unknown>;
|
||||
pluginWrites: PluginWrite[];
|
||||
overrides: DefaultOverrides;
|
||||
overridesChanged: boolean;
|
||||
coreKeysChanged: string[];
|
||||
}
|
||||
|
||||
type PluginRows = Map<string, Map<string, PluginSettingsRecord>>;
|
||||
|
||||
function decode(raw: string | null | undefined): unknown {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentPluginValue(
|
||||
info: DefaultKeyInfo,
|
||||
rows: PluginRows | undefined,
|
||||
): unknown {
|
||||
const row = rows?.get(info.namespace)?.get(info.key);
|
||||
return info.normalize(row ? decode(row.value) : undefined);
|
||||
}
|
||||
|
||||
export function applyOverlay(
|
||||
index: DefaultsIndex,
|
||||
overlay: LevelOverlay | undefined,
|
||||
): DefaultsIndex {
|
||||
if (!overlay) return index;
|
||||
const patch = (values: Map<string, unknown>) => {
|
||||
const next = new Map(values);
|
||||
for (const key of overlay.unset) next.delete(key);
|
||||
for (const [key, value] of overlay.set) next.set(key, value);
|
||||
return next;
|
||||
};
|
||||
if (overlay.level === "admin") {
|
||||
return { ...index, admin: patch(index.admin) };
|
||||
}
|
||||
if (overlay.level === "user" && overlay.userId) {
|
||||
const users = new Map(index.users);
|
||||
users.set(overlay.userId, patch(users.get(overlay.userId) ?? new Map()));
|
||||
return { ...index, users };
|
||||
}
|
||||
if (overlay.level === "folder" && overlay.folderId !== undefined) {
|
||||
const folders = new Map(index.folders);
|
||||
for (const [userId, byPath] of folders) {
|
||||
for (const [path, folder] of byPath) {
|
||||
if (folder.folderId !== overlay.folderId) continue;
|
||||
const copy = new Map(byPath);
|
||||
copy.set(path, { ...folder, values: patch(folder.values) });
|
||||
folders.set(userId, copy);
|
||||
}
|
||||
}
|
||||
return { ...index, folders };
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** What one key resolves to for one host, with the jump-host guard applied. */
|
||||
export function resolveForHost(
|
||||
info: DefaultKeyInfo,
|
||||
levels: HostLevels,
|
||||
hostId: number | null,
|
||||
): ResolvedHostDefault | undefined {
|
||||
const resolved = resolveKey(info, levels);
|
||||
if (!resolved || hostId === null || info.fullKey !== "core.jumpHosts") {
|
||||
return resolved;
|
||||
}
|
||||
return { ...resolved, value: withoutSelfJump(resolved.value, hostId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans one host: which namespaces get classified, and what each inheriting
|
||||
* key has to be rewritten to. Pure, apart from the catalog it is handed.
|
||||
*/
|
||||
export function planHost(
|
||||
row: HostRow,
|
||||
levels: HostLevels,
|
||||
catalog: DefaultsCatalog,
|
||||
pluginRows: PluginRows | undefined,
|
||||
options: { keys?: Set<string> } = {},
|
||||
): HostPlan {
|
||||
const stored = parseDefaultOverrides(row.defaultOverrides);
|
||||
const overrides: DefaultOverrides = { ...(stored ?? {}) };
|
||||
let overridesChanged = stored === null;
|
||||
|
||||
const byNamespace = new Map<string, DefaultKeyInfo[]>();
|
||||
for (const info of catalog.values()) {
|
||||
const list = byNamespace.get(info.namespace);
|
||||
if (list) list.push(info);
|
||||
else byNamespace.set(info.namespace, [info]);
|
||||
}
|
||||
|
||||
const current = (info: DefaultKeyInfo) =>
|
||||
info.namespace === CORE_NAMESPACE
|
||||
? readCoreDefault(info.key, row as unknown as Record<string, unknown>)
|
||||
: currentPluginValue(info, pluginRows);
|
||||
|
||||
for (const namespace of catalogNamespaces(catalog)) {
|
||||
if (overrides[namespace]) continue;
|
||||
overrides[namespace] = (byNamespace.get(namespace) ?? [])
|
||||
.filter((info) =>
|
||||
isOwnValue(info, current(info), resolveForHost(info, levels, row.id)),
|
||||
)
|
||||
.map((info) => info.key)
|
||||
.sort();
|
||||
overridesChanged = true;
|
||||
}
|
||||
|
||||
let columns: Record<string, unknown> = {};
|
||||
const pluginWrites: PluginWrite[] = [];
|
||||
const coreKeysChanged: string[] = [];
|
||||
|
||||
for (const info of catalog.values()) {
|
||||
if (options.keys && !options.keys.has(info.fullKey)) continue;
|
||||
if (overrides[info.namespace]?.includes(info.key)) continue;
|
||||
const resolved = resolveForHost(info, levels, row.id);
|
||||
if (!resolved) continue;
|
||||
const desired = info.normalize(resolved.value);
|
||||
if (defaultValuesEqual(current(info), desired)) continue;
|
||||
|
||||
if (info.namespace === CORE_NAMESPACE) {
|
||||
const base = { ...row, ...columns } as Record<string, unknown>;
|
||||
columns = { ...columns, ...writeCoreDefault(info.key, desired, base) };
|
||||
coreKeysChanged.push(info.key);
|
||||
} else {
|
||||
const manifestDefault = info.normalize(undefined);
|
||||
pluginWrites.push({
|
||||
hostId: row.id,
|
||||
pluginId: info.namespace,
|
||||
key: info.key,
|
||||
value: defaultValuesEqual(desired, manifestDefault)
|
||||
? undefined
|
||||
: desired,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (overridesChanged) columns.defaultOverrides = JSON.stringify(overrides);
|
||||
return {
|
||||
columns,
|
||||
pluginWrites,
|
||||
overrides,
|
||||
overridesChanged,
|
||||
coreKeysChanged,
|
||||
};
|
||||
}
|
||||
|
||||
/** The running plugins' manifests, and the catalog they make with core's. */
|
||||
export function currentCatalog(): {
|
||||
manifests: PluginManifest[];
|
||||
catalog: DefaultsCatalog;
|
||||
} {
|
||||
const manifests = hostSettingsPlugins();
|
||||
return { manifests, catalog: buildCatalog(manifests) };
|
||||
}
|
||||
|
||||
export async function loadPluginRows(
|
||||
hostIds: number[],
|
||||
): Promise<Map<number, PluginRows>> {
|
||||
const result = new Map<number, PluginRows>();
|
||||
const repository = createCurrentPluginSettingsRepository();
|
||||
for (let i = 0; i < hostIds.length; i += 500) {
|
||||
const part = hostIds.slice(i, i + 500).map(String);
|
||||
for (const row of await repository.getAllForScopeIds("host", part)) {
|
||||
const hostId = Number(row.scopeId);
|
||||
let byPlugin = result.get(hostId);
|
||||
if (!byPlugin) {
|
||||
byPlugin = new Map();
|
||||
result.set(hostId, byPlugin);
|
||||
}
|
||||
let byKey = byPlugin.get(row.pluginId);
|
||||
if (!byKey) {
|
||||
byKey = new Map();
|
||||
byPlugin.set(row.pluginId, byKey);
|
||||
}
|
||||
byKey.set(row.key, row);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadPlacement(
|
||||
userIds: string[],
|
||||
): Promise<Map<number, HostPlacement>> {
|
||||
const rows =
|
||||
await createCurrentHostDefaultsRepository().listHostPlacement(userIds);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.id,
|
||||
{ folder: row.folder, parentHostId: row.parentHostId },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs the side effects every changed host needs, after its write. */
|
||||
async function afterHostsChanged(
|
||||
changed: Array<{ row: HostRow; plan: HostPlan }>,
|
||||
): Promise<void> {
|
||||
if (changed.length === 0) return;
|
||||
try {
|
||||
const { markChanged } = await import("../../sync/server/feed.js");
|
||||
markChanged();
|
||||
} catch {
|
||||
// Sync is not loaded in every context.
|
||||
}
|
||||
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
const notified = new Map<string, unknown>();
|
||||
let secrets: {
|
||||
resyncHost: (hostId: number) => Promise<unknown>;
|
||||
} | null = null;
|
||||
for (const { row, plan } of changed) {
|
||||
try {
|
||||
pluginEvents.emit(TOPICS.hostUpdated, {
|
||||
hostId: row.id,
|
||||
userId: row.userId,
|
||||
});
|
||||
} catch {
|
||||
// A subscriber must not fail the write that already happened.
|
||||
}
|
||||
for (const write of plan.pluginWrites) {
|
||||
notified.set(`${write.pluginId}\u0000${write.key}`, write.value);
|
||||
}
|
||||
if (
|
||||
plan.coreKeysChanged.includes("auth") ||
|
||||
plan.coreKeysChanged.includes("username")
|
||||
) {
|
||||
try {
|
||||
if (!secrets) {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
secrets = SharedHostSecretsManager.getInstance();
|
||||
}
|
||||
await secrets.resyncHost(row.id);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to resync shared host after defaults", {
|
||||
operation: "host_defaults_resync",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [pair, value] of notified) {
|
||||
const [pluginId, key] = pair.split("\u0000");
|
||||
notifySettingChange(pluginId, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies and rewrites a set of hosts. Returns the hosts whose values or
|
||||
* overrides changed; with `dryRun` nothing is written.
|
||||
*/
|
||||
export async function materializeHosts(
|
||||
target: HostDefaultsTarget,
|
||||
options: MaterializeOptions = {},
|
||||
): Promise<MaterializeResult> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const rows = (await repository.listHosts(target)).filter(
|
||||
// A desktop's read-only copy of a shared host follows its owner's server.
|
||||
(row) => !row.sharedSource,
|
||||
);
|
||||
if (rows.length === 0) return { changedHostIds: [] };
|
||||
|
||||
const { catalog } = currentCatalog();
|
||||
const userIds = [...new Set(rows.map((row) => row.userId))];
|
||||
const [index, placement, pluginRows] = await Promise.all([
|
||||
loadDefaultsIndex(userIds),
|
||||
loadPlacement(userIds),
|
||||
loadPluginRows(rows.map((row) => row.id)),
|
||||
]);
|
||||
const withOverlay = applyOverlay(index, options.overlay);
|
||||
|
||||
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
|
||||
for (const row of rows) {
|
||||
const levels = levelsForHost(
|
||||
withOverlay,
|
||||
row.userId,
|
||||
effectiveFolderPath(row.id, placement),
|
||||
);
|
||||
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
|
||||
keys: options.keys,
|
||||
});
|
||||
const valuesChanged =
|
||||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
|
||||
plan.pluginWrites.length > 0;
|
||||
if (options.dryRun) {
|
||||
if (valuesChanged) changed.push({ row, plan });
|
||||
continue;
|
||||
}
|
||||
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return { changedHostIds: changed.map(({ row }) => row.id) };
|
||||
}
|
||||
|
||||
await repository.updateHosts(
|
||||
changed.map(({ row, plan }) => ({
|
||||
id: row.id,
|
||||
values: plan.columns as Partial<HostRow>,
|
||||
})),
|
||||
);
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
for (const { plan } of changed) {
|
||||
for (const write of plan.pluginWrites) {
|
||||
if (write.value === undefined) {
|
||||
await settings.delete(
|
||||
write.pluginId,
|
||||
"host",
|
||||
String(write.hostId),
|
||||
write.key,
|
||||
);
|
||||
} else {
|
||||
await settings.set(
|
||||
write.pluginId,
|
||||
"host",
|
||||
String(write.hostId),
|
||||
write.key,
|
||||
JSON.stringify(write.value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const valueChanges = changed.filter(
|
||||
({ plan }) =>
|
||||
plan.pluginWrites.length > 0 ||
|
||||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
|
||||
);
|
||||
await afterHostsChanged(valueChanges);
|
||||
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Changes which keys a host sets itself, without going through a full host
|
||||
* write: a patch that sets a value makes it the host's own, and a reset hands
|
||||
* it back to the defaults.
|
||||
*/
|
||||
|
||||
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
parseDefaultOverrides,
|
||||
type DefaultOverrides,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import { catalogNamespaces } from "./catalog.js";
|
||||
import { currentCatalog } from "./materialize.js";
|
||||
import { recompute } from "./recompute.js";
|
||||
|
||||
export interface OverrideChange {
|
||||
/** Keys that become the host's own, as [namespace, key]. */
|
||||
own?: Array<[string, string]>;
|
||||
/** Keys that follow the defaults again. */
|
||||
inherit?: Array<[string, string]>;
|
||||
/** Namespaces whose every key follows the defaults again. */
|
||||
inheritNamespaces?: string[];
|
||||
/** Every key follows the defaults again. */
|
||||
inheritAll?: boolean;
|
||||
}
|
||||
|
||||
export function applyOverrideChange(
|
||||
current: DefaultOverrides | null,
|
||||
change: OverrideChange,
|
||||
allNamespaces: string[] = [],
|
||||
): DefaultOverrides {
|
||||
const next: DefaultOverrides = {};
|
||||
for (const [namespace, keys] of Object.entries(current ?? {})) {
|
||||
next[namespace] = [...keys];
|
||||
}
|
||||
if (change.inheritAll) {
|
||||
for (const namespace of [...Object.keys(next), ...allNamespaces]) {
|
||||
next[namespace] = [];
|
||||
}
|
||||
}
|
||||
for (const namespace of change.inheritNamespaces ?? []) {
|
||||
next[namespace] = [];
|
||||
}
|
||||
for (const [namespace, key] of change.inherit ?? []) {
|
||||
if (next[namespace]) {
|
||||
next[namespace] = next[namespace].filter((own) => own !== key);
|
||||
}
|
||||
}
|
||||
for (const [namespace, key] of change.own ?? []) {
|
||||
// A namespace nobody classified yet stays that way: its first pass
|
||||
// compares every value, this one included, and would keep it.
|
||||
if (!next[namespace]) continue;
|
||||
const keys = new Set(next[namespace] ?? []);
|
||||
keys.add(key);
|
||||
next[namespace] = [...keys].sort();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a change to a set of hosts. A change that hands keys back to the
|
||||
* defaults is followed by a pass that writes what they now resolve to.
|
||||
*/
|
||||
export async function changeHostOverrides(
|
||||
hostIds: number[],
|
||||
change: OverrideChange,
|
||||
): Promise<void> {
|
||||
if (hostIds.length === 0) return;
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const rows = await repository.listHosts({ hostIds });
|
||||
const namespaces = catalogNamespaces(currentCatalog().catalog);
|
||||
const patches: Array<{ id: number; values: { defaultOverrides: string } }> =
|
||||
[];
|
||||
for (const row of rows) {
|
||||
const current = parseDefaultOverrides(row.defaultOverrides);
|
||||
const next = applyOverrideChange(current, change, namespaces);
|
||||
const encoded = JSON.stringify(next);
|
||||
if (encoded !== row.defaultOverrides) {
|
||||
patches.push({ id: row.id, values: { defaultOverrides: encoded } });
|
||||
}
|
||||
}
|
||||
await repository.updateHosts(patches);
|
||||
const resets =
|
||||
change.inheritAll ||
|
||||
(change.inheritNamespaces?.length ?? 0) > 0 ||
|
||||
(change.inherit?.length ?? 0) > 0;
|
||||
if (resets) await recompute({ hostIds });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Personal fields: look-and-feel values (a terminal's font, a remote
|
||||
* desktop's color depth) that a user a host is shared with sees through
|
||||
* their own defaults while the host follows its defaults. The stored value is
|
||||
* the owner's; this swaps it for the viewer's at read time.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
type HostPluginSettings,
|
||||
} from "../../database/routes/host-plugin-settings.js";
|
||||
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||
import { pluginCatalogEntries, type DefaultKeyInfo } from "./catalog.js";
|
||||
import { loadDefaultsIndex } from "./levels.js";
|
||||
import { resolveKey, type HostLevels } from "./resolve.js";
|
||||
|
||||
function personalEntries(manifests: PluginManifest[]): DefaultKeyInfo[] {
|
||||
return manifests.flatMap((manifest) =>
|
||||
pluginCatalogEntries(manifest).filter((entry) => entry.personal),
|
||||
);
|
||||
}
|
||||
|
||||
async function viewerLevels(viewerId: string): Promise<HostLevels> {
|
||||
const index = await loadDefaultsIndex([viewerId]);
|
||||
return {
|
||||
admin: index.admin,
|
||||
user: index.users.get(viewerId) ?? new Map(),
|
||||
folders: [],
|
||||
};
|
||||
}
|
||||
|
||||
function follows(row: HostRow, entry: DefaultKeyInfo): boolean {
|
||||
const overrides = parseDefaultOverrides(row.defaultOverrides);
|
||||
const own = overrides?.[entry.namespace];
|
||||
return !!own && !own.includes(entry.key);
|
||||
}
|
||||
|
||||
/** Replaces personal values in a host list's plugin settings, in place. */
|
||||
export async function applyPersonalHostValues(
|
||||
settings: Map<number, HostPluginSettings>,
|
||||
viewerId: string,
|
||||
): Promise<void> {
|
||||
const entries = personalEntries(hostSettingsPlugins());
|
||||
if (entries.length === 0 || settings.size === 0) return;
|
||||
const rows = (
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [...settings.keys()],
|
||||
})
|
||||
).filter((row) => row.userId !== viewerId);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const levels = await viewerLevels(viewerId);
|
||||
for (const row of rows) {
|
||||
const values = settings.get(row.id);
|
||||
if (!values) continue;
|
||||
for (const entry of entries) {
|
||||
if (!follows(row, entry) || !values[entry.namespace]) continue;
|
||||
const resolved = resolveKey(entry, levels);
|
||||
if (resolved) values[entry.namespace][entry.key] = resolved.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One personal value as a user sees it, when that differs from the stored one. */
|
||||
export async function personalHostValue(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
viewerId: string,
|
||||
key: string,
|
||||
): Promise<{ applies: boolean; value?: unknown }> {
|
||||
const entry = pluginCatalogEntries(manifest).find(
|
||||
(candidate) => candidate.key === key && candidate.personal,
|
||||
);
|
||||
if (!entry || !Number.isInteger(hostId)) return { applies: false };
|
||||
const [row] = await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
});
|
||||
if (!row || row.userId === viewerId || !follows(row, entry)) {
|
||||
return { applies: false };
|
||||
}
|
||||
const resolved = resolveKey(entry, await viewerLevels(viewerId));
|
||||
return resolved
|
||||
? { applies: true, value: resolved.value }
|
||||
: { applies: false };
|
||||
}
|
||||
|
||||
/** A host field as a user's hosts get it with nothing more specific set. */
|
||||
export async function userHostDefault(
|
||||
manifest: PluginManifest,
|
||||
userId: string,
|
||||
key: string,
|
||||
): Promise<unknown> {
|
||||
const entry = pluginCatalogEntries(manifest).find(
|
||||
(candidate) => candidate.key === key,
|
||||
);
|
||||
if (!entry) return undefined;
|
||||
return resolveKey(entry, await viewerLevels(userId))?.value;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Runs materialize passes one at a time, so two default changes can never
|
||||
* write the same host at once. A server-wide change can touch every host, so
|
||||
* it runs as a job the admin panel polls instead of holding the request open.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||
import {
|
||||
materializeHosts,
|
||||
type HostDefaultsTarget,
|
||||
type MaterializeResult,
|
||||
} from "./materialize.js";
|
||||
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = queue.catch(() => {}).then(task);
|
||||
queue = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
export interface RecomputeJob {
|
||||
id: string;
|
||||
status: "running" | "done" | "failed";
|
||||
changedHosts: number;
|
||||
error?: string;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
const jobs = new Map<string, RecomputeJob>();
|
||||
const JOB_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
function pruneJobs(): void {
|
||||
const cutoff = Date.now() - JOB_TTL_MS;
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.finishedAt && job.finishedAt < cutoff) jobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Which hosts a level's defaults reach. A folder's owner is resolved by the caller. */
|
||||
export function targetForScope(
|
||||
scope: HostDefaultsScope,
|
||||
folderOwnerId?: string,
|
||||
): HostDefaultsTarget {
|
||||
if (scope.level === "admin") return { all: true };
|
||||
if (scope.level === "user") return { userIds: [String(scope.userId)] };
|
||||
return { userIds: folderOwnerId ? [folderOwnerId] : [] };
|
||||
}
|
||||
|
||||
export function recompute(
|
||||
target: HostDefaultsTarget,
|
||||
keys?: Set<string>,
|
||||
): Promise<MaterializeResult> {
|
||||
return enqueue(() => materializeHosts(target, { keys }));
|
||||
}
|
||||
|
||||
/** Starts a pass in the background and returns its job. */
|
||||
export function startRecomputeJob(
|
||||
target: HostDefaultsTarget,
|
||||
keys?: Set<string>,
|
||||
): RecomputeJob {
|
||||
pruneJobs();
|
||||
const job: RecomputeJob = {
|
||||
id: randomUUID(),
|
||||
status: "running",
|
||||
changedHosts: 0,
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
recompute(target, keys)
|
||||
.then((result) => {
|
||||
job.status = "done";
|
||||
job.changedHosts = result.changedHostIds.length;
|
||||
})
|
||||
.catch((error) => {
|
||||
job.status = "failed";
|
||||
job.error = error instanceof Error ? error.message : String(error);
|
||||
databaseLogger.error("Host defaults recompute failed", error, {
|
||||
operation: "host_defaults_recompute",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
job.finishedAt = Date.now();
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getRecomputeJob(id: string): RecomputeJob | undefined {
|
||||
return jobs.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies and brings every host up to date. Run after boot and when a
|
||||
* plugin starts, so a host or namespace nobody has classified yet catches up.
|
||||
*/
|
||||
export function recomputeEverything(reason: string): void {
|
||||
recompute({ all: true })
|
||||
.then((result) => {
|
||||
if (result.changedHostIds.length > 0) {
|
||||
databaseLogger.info("Applied host defaults", {
|
||||
operation: "host_defaults_recompute",
|
||||
reason,
|
||||
changedHosts: result.changedHostIds.length,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.warn("Could not apply host defaults", {
|
||||
operation: "host_defaults_recompute",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Host default resolution. No database access: the levels come in as maps,
|
||||
* so every rule here is testable on its own.
|
||||
*
|
||||
* A key resolves from the deepest folder a host sits in up to its root
|
||||
* folder, then the owner's own defaults, then the server's, then the key's
|
||||
* built-in value. A level the key does not allow is skipped.
|
||||
*/
|
||||
|
||||
import {
|
||||
defaultValuesEqual,
|
||||
type HostDefaultSource,
|
||||
type ResolvedHostDefault,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import type { DefaultKeyInfo } from "./catalog.js";
|
||||
|
||||
export type LevelMap = Map<string, unknown>;
|
||||
|
||||
export interface FolderLevel {
|
||||
folderId: number;
|
||||
name: string;
|
||||
values: LevelMap;
|
||||
}
|
||||
|
||||
export interface HostLevels {
|
||||
admin: LevelMap;
|
||||
user: LevelMap;
|
||||
/** Root first, deepest last. */
|
||||
folders: FolderLevel[];
|
||||
}
|
||||
|
||||
export const FOLDER_SEPARATOR = " / ";
|
||||
|
||||
/** "A / B / C" -> ["A", "A / B", "A / B / C"]. */
|
||||
export function folderChainPaths(path: string | null | undefined): string[] {
|
||||
if (!path) return [];
|
||||
const segments = path.split(FOLDER_SEPARATOR).filter(Boolean);
|
||||
return segments.map((_, index) =>
|
||||
segments.slice(0, index + 1).join(FOLDER_SEPARATOR),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostPlacement {
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder whose defaults a host follows. A sub-host has no folder of its
|
||||
* own, so it takes the nearest ancestor's.
|
||||
*/
|
||||
export function effectiveFolderPath(
|
||||
hostId: number,
|
||||
placement: Map<number, HostPlacement>,
|
||||
): string | null {
|
||||
const seen = new Set<number>();
|
||||
let current: number | null = hostId;
|
||||
while (current !== null && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const own = placement.get(current);
|
||||
if (!own) return null;
|
||||
if (own.folder) return own.folder;
|
||||
current = own.parentHostId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveKey(
|
||||
info: DefaultKeyInfo,
|
||||
levels: HostLevels,
|
||||
): ResolvedHostDefault | undefined {
|
||||
const allowed = new Set(info.levels);
|
||||
if (allowed.has("folder")) {
|
||||
for (let i = levels.folders.length - 1; i >= 0; i--) {
|
||||
const folder = levels.folders[i];
|
||||
if (folder.values.has(info.fullKey)) {
|
||||
return {
|
||||
value: info.normalize(folder.values.get(info.fullKey)),
|
||||
source: {
|
||||
level: "folder",
|
||||
folderId: folder.folderId,
|
||||
folderName: folder.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const order: Array<["user" | "admin", LevelMap]> = [
|
||||
["user", levels.user],
|
||||
["admin", levels.admin],
|
||||
];
|
||||
for (const [level, values] of order) {
|
||||
if (allowed.has(level) && values.has(info.fullKey)) {
|
||||
return {
|
||||
value: info.normalize(values.get(info.fullKey)),
|
||||
source: { level },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (info.builtin === undefined) return undefined;
|
||||
const source: HostDefaultSource = { level: "builtin" };
|
||||
return { value: info.normalize(info.builtin), source };
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a host's value is its own rather than the default: it differs from
|
||||
* what resolves, or nothing resolves and the host has a value at all.
|
||||
*/
|
||||
export function isOwnValue(
|
||||
info: DefaultKeyInfo,
|
||||
hostValue: unknown,
|
||||
resolved: ResolvedHostDefault | undefined,
|
||||
): boolean {
|
||||
const own = info.normalize(hostValue);
|
||||
if (!resolved) return !isEmpty(own);
|
||||
return !defaultValuesEqual(own, resolved.value);
|
||||
}
|
||||
|
||||
/** A host never jumps through itself, even when a folder default says to. */
|
||||
export function withoutSelfJump(value: unknown, hostId: number): unknown {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.filter(
|
||||
(entry) => Number((entry as { hostId?: unknown })?.hostId) !== hostId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Reading, checking and saving one level of host defaults, and resolving a
|
||||
* host's defaults for the editor. The routes are a thin layer over this.
|
||||
*/
|
||||
|
||||
import {
|
||||
coerceSettingValue,
|
||||
validateSettingValue,
|
||||
} from "@termix/plugin-sdk/settings";
|
||||
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||
import { validateSettingsSave } from "../../plugins/settings.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
SECRET_AUTH_TYPES,
|
||||
normalizeAuthDefault,
|
||||
splitDefaultKey,
|
||||
type ResolvedHostDefault,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import type { DefaultKeyInfo, DefaultsCatalog } from "./catalog.js";
|
||||
import {
|
||||
decodeDefaultValue,
|
||||
encodeDefaultValue,
|
||||
loadDefaultsIndex,
|
||||
levelsForHost,
|
||||
} from "./levels.js";
|
||||
import {
|
||||
currentCatalog,
|
||||
loadPlacement,
|
||||
resolveForHost,
|
||||
} from "./materialize.js";
|
||||
import {
|
||||
effectiveFolderPath,
|
||||
folderChainPaths,
|
||||
type HostLevels,
|
||||
} from "./resolve.js";
|
||||
|
||||
export interface LevelChangeInput {
|
||||
set?: Record<string, unknown>;
|
||||
unset?: string[];
|
||||
}
|
||||
|
||||
export interface ValidatedLevelChange {
|
||||
set: Map<string, unknown>;
|
||||
unset: Set<string>;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
/** What a level holds, by "namespace.key". */
|
||||
export async function readLevel(
|
||||
scope: HostDefaultsScope,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const rows = await createCurrentHostDefaultsRepository().listScope(scope);
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** The levels above one, which is what a key reads when this level leaves it unset. */
|
||||
export async function levelsAbove(
|
||||
scope: HostDefaultsScope,
|
||||
folderOwner?: { userId: string; name: string },
|
||||
): Promise<HostLevels> {
|
||||
const empty: HostLevels = { admin: new Map(), user: new Map(), folders: [] };
|
||||
if (scope.level === "admin") return empty;
|
||||
const ownerId =
|
||||
scope.level === "user" ? String(scope.userId) : folderOwner?.userId;
|
||||
if (!ownerId) return empty;
|
||||
const index = await loadDefaultsIndex([ownerId]);
|
||||
if (scope.level === "user") {
|
||||
return { ...empty, admin: index.admin };
|
||||
}
|
||||
const parents = folderChainPaths(folderOwner?.name).slice(0, -1);
|
||||
const own = index.folders.get(ownerId);
|
||||
return {
|
||||
admin: index.admin,
|
||||
user: index.users.get(ownerId) ?? new Map(),
|
||||
folders: parents
|
||||
.map((path) => own?.get(path))
|
||||
.filter((folder): folder is NonNullable<typeof folder> => !!folder),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAll(
|
||||
catalog: DefaultsCatalog,
|
||||
levels: HostLevels,
|
||||
hostId: number | null = null,
|
||||
): Record<string, ResolvedHostDefault> {
|
||||
const result: Record<string, ResolvedHostDefault> = {};
|
||||
for (const info of catalog.values()) {
|
||||
const resolved = resolveForHost(info, levels, hostId);
|
||||
if (resolved) result[info.fullKey] = resolved;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CheckContext {
|
||||
actorId: string;
|
||||
/** Whose rows a user or folder level may point at. */
|
||||
ownerId: string | null;
|
||||
canUseCredential: (credentialId: number, userId: string) => Promise<boolean>;
|
||||
canUseHost: (hostId: number, userId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
async function checkCoreValue(
|
||||
info: DefaultKeyInfo,
|
||||
value: unknown,
|
||||
scope: HostDefaultsScope,
|
||||
context: CheckContext,
|
||||
): Promise<string | null> {
|
||||
if (info.key === "auth") {
|
||||
const auth = normalizeAuthDefault(value);
|
||||
if (!auth) return "An auth default needs an auth type";
|
||||
if (SECRET_AUTH_TYPES.includes(auth.authType)) {
|
||||
return "Password and key logins belong to one host and cannot be a default";
|
||||
}
|
||||
if (auth.authType === "credential") {
|
||||
if (scope.level === "admin") {
|
||||
return "A credential belongs to one user, so it cannot be a server default";
|
||||
}
|
||||
if (
|
||||
auth.credentialId === null ||
|
||||
!context.ownerId ||
|
||||
!(await context.canUseCredential(auth.credentialId, context.ownerId))
|
||||
) {
|
||||
return "That credential is not available";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (info.key === "jumpHosts" && Array.isArray(value) && context.ownerId) {
|
||||
for (const entry of value as Array<{ hostId?: unknown }>) {
|
||||
const hostId = Number(entry?.hostId);
|
||||
if (
|
||||
!Number.isInteger(hostId) ||
|
||||
!(await context.canUseHost(hostId, context.ownerId))
|
||||
) {
|
||||
return "A jump host in the chain is not available";
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Checks a level change against the catalog, each key's levels and its field. */
|
||||
export async function validateLevelChange(
|
||||
scope: HostDefaultsScope,
|
||||
input: LevelChangeInput,
|
||||
context: CheckContext,
|
||||
catalog: DefaultsCatalog = currentCatalog().catalog,
|
||||
): Promise<ValidatedLevelChange> {
|
||||
const set = new Map<string, unknown>();
|
||||
const unset = new Set<string>();
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const fullKey of input.unset ?? []) {
|
||||
if (typeof fullKey === "string") unset.add(fullKey);
|
||||
}
|
||||
|
||||
const pluginValues = new Map<string, Record<string, unknown>>();
|
||||
for (const [fullKey, raw] of Object.entries(input.set ?? {})) {
|
||||
const info = catalog.get(fullKey);
|
||||
if (!info) {
|
||||
errors[fullKey] = "Not a host setting that can have a default";
|
||||
continue;
|
||||
}
|
||||
if (!info.levels.includes(scope.level)) {
|
||||
errors[fullKey] = `Cannot be set as a ${scope.level} default`;
|
||||
continue;
|
||||
}
|
||||
if (info.namespace === CORE_NAMESPACE) {
|
||||
const error = await checkCoreValue(info, raw, scope, context);
|
||||
if (error) {
|
||||
errors[fullKey] = error;
|
||||
continue;
|
||||
}
|
||||
set.set(fullKey, info.normalize(raw));
|
||||
continue;
|
||||
}
|
||||
const field = info.field!;
|
||||
const coerced = coerceSettingValue(field, raw);
|
||||
const error = validateSettingValue(field, coerced);
|
||||
if (error) {
|
||||
errors[fullKey] = error;
|
||||
continue;
|
||||
}
|
||||
set.set(fullKey, info.normalize(coerced));
|
||||
const own = pluginValues.get(info.namespace) ?? {};
|
||||
own[info.key] = coerced;
|
||||
pluginValues.set(info.namespace, own);
|
||||
}
|
||||
|
||||
for (const [pluginId, values] of pluginValues) {
|
||||
const rejected = await validateSettingsSave(
|
||||
pluginId,
|
||||
"host",
|
||||
null,
|
||||
values,
|
||||
{ level: scope.level },
|
||||
);
|
||||
for (const [key, message] of Object.entries(rejected)) {
|
||||
errors[key === "_" ? pluginId : `${pluginId}.${key}`] = message;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of set.keys()) unset.delete(key);
|
||||
return { set, unset, errors };
|
||||
}
|
||||
|
||||
export async function saveLevel(
|
||||
scope: HostDefaultsScope,
|
||||
change: ValidatedLevelChange,
|
||||
actorId: string,
|
||||
): Promise<void> {
|
||||
await createCurrentHostDefaultsRepository().apply(
|
||||
scope,
|
||||
[...change.set].map(([fullKey, value]) => {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
return { namespace, key, value: encodeDefaultValue(value) };
|
||||
}),
|
||||
[...change.unset].map((fullKey) => {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
return { namespace, key };
|
||||
}),
|
||||
actorId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host (or a new one in a folder or under a parent) resolves every
|
||||
* key to, with where each value comes from.
|
||||
*/
|
||||
export async function resolveForEditor(input: {
|
||||
ownerId: string;
|
||||
hostId?: number | null;
|
||||
folder?: string | null;
|
||||
parentHostId?: number | null;
|
||||
}): Promise<Record<string, ResolvedHostDefault>> {
|
||||
const { catalog } = currentCatalog();
|
||||
const placement = await loadPlacement([input.ownerId]);
|
||||
const self = input.hostId ?? -1;
|
||||
const stored = placement.get(self);
|
||||
placement.set(self, {
|
||||
folder:
|
||||
input.folder !== undefined ? input.folder : (stored?.folder ?? null),
|
||||
parentHostId:
|
||||
input.parentHostId !== undefined
|
||||
? input.parentHostId
|
||||
: (stored?.parentHostId ?? null),
|
||||
});
|
||||
const index = await loadDefaultsIndex([input.ownerId]);
|
||||
const levels = levelsForHost(
|
||||
index,
|
||||
input.ownerId,
|
||||
effectiveFolderPath(self, placement),
|
||||
);
|
||||
return resolveAll(catalog, levels, input.hostId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder's credential is its auth default: hosts in the folder that follow
|
||||
* their defaults sign in with it. Null clears it.
|
||||
*/
|
||||
export async function setFolderCredentialDefault(
|
||||
userId: string,
|
||||
folderId: number,
|
||||
credentialId: number | null,
|
||||
): Promise<void> {
|
||||
await createCurrentHostDefaultsRepository().apply(
|
||||
{ level: "folder", folderId, userId },
|
||||
credentialId === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
namespace: CORE_NAMESPACE,
|
||||
key: "auth",
|
||||
value: encodeDefaultValue(
|
||||
normalizeAuthDefault({ authType: "credential", credentialId }),
|
||||
),
|
||||
},
|
||||
],
|
||||
credentialId === null ? [{ namespace: CORE_NAMESPACE, key: "auth" }] : [],
|
||||
userId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A user's own and folder host defaults in the per-user export database.
|
||||
* Folders travel by path, since their ids mean nothing on another server.
|
||||
* An import is checked like any other save, so a credential or jump host
|
||||
* the importing user cannot use is skipped, and a key the level already
|
||||
* holds is left as it is.
|
||||
*/
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { findUsableCredential } from "../usable-credential.js";
|
||||
import { decodeDefaultValue } from "./levels.js";
|
||||
import { readLevel, saveLevel, validateLevelChange } from "./service.js";
|
||||
|
||||
export async function writeHostDefaultsToExport(
|
||||
exportDb: Database.Database,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
exportDb.exec(`
|
||||
CREATE TABLE host_defaults (
|
||||
level TEXT NOT NULL,
|
||||
folder_name TEXT,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT
|
||||
);
|
||||
`);
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const folders = new Map(
|
||||
(await repository.listFolders([userId])).map((folder) => [
|
||||
folder.id,
|
||||
folder.name,
|
||||
]),
|
||||
);
|
||||
const insert = exportDb.prepare(
|
||||
"INSERT INTO host_defaults (level, folder_name, namespace, key, value) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
for (const row of await repository.listOwnedBy(userId)) {
|
||||
const folderName =
|
||||
row.level === "folder" && row.folderId !== null
|
||||
? folders.get(row.folderId)
|
||||
: null;
|
||||
if (row.level === "folder" && !folderName) continue;
|
||||
insert.run(row.level, folderName, row.namespace, row.key, row.value);
|
||||
}
|
||||
}
|
||||
|
||||
export async function importHostDefaults(
|
||||
importDb: Database.Database,
|
||||
userId: string,
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let rows: Array<{
|
||||
level: string;
|
||||
folder_name: string | null;
|
||||
namespace: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>;
|
||||
try {
|
||||
rows = importDb.prepare("SELECT * FROM host_defaults").all() as never;
|
||||
} catch {
|
||||
return { imported: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const groups = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rows) {
|
||||
const group =
|
||||
row.level === "folder" && row.folder_name
|
||||
? `f:${row.folder_name}`
|
||||
: row.level === "user"
|
||||
? "u"
|
||||
: null;
|
||||
if (!group) continue;
|
||||
const values = groups.get(group) ?? {};
|
||||
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
|
||||
groups.set(group, values);
|
||||
}
|
||||
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const context = {
|
||||
actorId: userId,
|
||||
ownerId: userId,
|
||||
canUseCredential: async (credentialId: number, owner: string) =>
|
||||
(await findUsableCredential(credentialId, owner)) !== null,
|
||||
canUseHost: async (hostId: number, owner: string) =>
|
||||
(await permissions.canAccessHost(owner, hostId, "connect")).hasAccess,
|
||||
};
|
||||
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
for (const [group, values] of groups) {
|
||||
const scope: HostDefaultsScope =
|
||||
group === "u"
|
||||
? { level: "user", userId }
|
||||
: {
|
||||
level: "folder",
|
||||
userId,
|
||||
folderId: await repository.ensureFolder(userId, group.slice(2)),
|
||||
};
|
||||
const existing = await readLevel(scope);
|
||||
const fresh = Object.fromEntries(
|
||||
Object.entries(values).filter(([key]) => !(key in existing)),
|
||||
);
|
||||
skipped += Object.keys(values).length - Object.keys(fresh).length;
|
||||
const change = await validateLevelChange(scope, { set: fresh }, context);
|
||||
skipped += Object.keys(change.errors).length;
|
||||
if (change.set.size === 0) continue;
|
||||
await saveLevel(scope, { ...change, errors: {} }, userId);
|
||||
imported += change.set.size;
|
||||
}
|
||||
return { imported, skipped };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* How a host write decides which core keys the host sets itself.
|
||||
*
|
||||
* - A write that carries `defaultOverrides` says so outright (the host
|
||||
* editor). Values it sends for inherited keys are replaced by what
|
||||
* resolves.
|
||||
* - An update without one (an API client, Termix-Mobile) keeps what was
|
||||
* overridden, and a key whose value changed becomes overridden.
|
||||
* - A create without one (import, quick connect, enroll) inherits every key
|
||||
* whose value matches what resolves, or that it left out.
|
||||
*
|
||||
* Plugin namespaces are not in the host body. They keep their state here and
|
||||
* are classified or rewritten by the materialize pass that follows the write.
|
||||
*/
|
||||
|
||||
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
defaultValuesEqual,
|
||||
parseDefaultOverrides,
|
||||
type DefaultOverrides,
|
||||
} from "../../../types/host-defaults.js";
|
||||
import { coreCatalogEntries } from "./catalog.js";
|
||||
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
|
||||
import { loadDefaultsIndex, levelsForHost } from "./levels.js";
|
||||
import { loadPlacement, resolveForHost } from "./materialize.js";
|
||||
import { effectiveFolderPath, isOwnValue } from "./resolve.js";
|
||||
|
||||
export interface HostDefaultsWrite {
|
||||
/** The host's owner, whose defaults apply. */
|
||||
ownerId: string;
|
||||
/** Null on create. */
|
||||
hostId: number | null;
|
||||
/** The column object the route is about to write. Changed in place. */
|
||||
columns: Record<string, unknown>;
|
||||
/** The request body as sent. */
|
||||
body: Record<string, unknown>;
|
||||
/** The row before an update. */
|
||||
stored?: HostRow | null;
|
||||
/** Core keys this caller may not change, which keep their state. */
|
||||
lockedKeys?: string[];
|
||||
}
|
||||
|
||||
/** Body fields each core key is read from, to tell a left-out key on create. */
|
||||
const BODY_FIELDS: Record<string, string[]> = {
|
||||
username: ["username"],
|
||||
sshPort: ["sshPort", "port"],
|
||||
auth: ["authType", "authMethod", "credentialId"],
|
||||
forceKeyboardInteractive: ["forceKeyboardInteractive"],
|
||||
keepaliveInterval: ["sshOptions", "terminalConfig"],
|
||||
keepaliveCountMax: ["sshOptions", "terminalConfig"],
|
||||
allowLegacyAlgorithms: ["sshOptions", "terminalConfig"],
|
||||
agentForwarding: ["sshOptions", "terminalConfig"],
|
||||
environmentVariables: ["sshOptions", "terminalConfig"],
|
||||
socks5: ["useSocks5"],
|
||||
jumpHosts: ["jumpHosts"],
|
||||
portKnockSequence: ["portKnockSequence"],
|
||||
statusCheckEnabled: ["statusCheckEnabled"],
|
||||
statusCheckInterval: ["statusCheckInterval"],
|
||||
};
|
||||
|
||||
function leftOut(body: Record<string, unknown>, key: string): boolean {
|
||||
const fields = BODY_FIELDS[key] ?? [key];
|
||||
return fields.every((field) => body[field] === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills inherited core keys into `columns` and returns the host's overrides.
|
||||
* The caller stores them as `defaultOverrides`.
|
||||
*/
|
||||
export async function applyHostDefaultsToWrite(
|
||||
write: HostDefaultsWrite,
|
||||
): Promise<DefaultOverrides> {
|
||||
const { ownerId, hostId, columns, body, stored } = write;
|
||||
const sent = parseDefaultOverrides(body.defaultOverrides);
|
||||
const before = parseDefaultOverrides(stored?.defaultOverrides);
|
||||
const locked = new Set(write.lockedKeys ?? []);
|
||||
|
||||
const placement = await loadPlacement([ownerId]);
|
||||
const self = hostId ?? -1;
|
||||
placement.set(self, {
|
||||
folder: (columns.folder !== undefined
|
||||
? columns.folder
|
||||
: (stored?.folder ?? null)) as string | null,
|
||||
parentHostId: (columns.parentHostId !== undefined
|
||||
? columns.parentHostId
|
||||
: (stored?.parentHostId ?? null)) as number | null,
|
||||
});
|
||||
const index = await loadDefaultsIndex([ownerId]);
|
||||
const levels = levelsForHost(
|
||||
index,
|
||||
ownerId,
|
||||
effectiveFolderPath(self, placement),
|
||||
);
|
||||
|
||||
const storedColumns = (stored ?? {}) as Record<string, unknown>;
|
||||
// A partial write (a plugin's ctx.hosts.update) leaves the rest as stored.
|
||||
const incoming = (key: string) =>
|
||||
readCoreDefault(key, { ...storedColumns, ...columns });
|
||||
const previous = (key: string) => readCoreDefault(key, storedColumns);
|
||||
|
||||
const overridden: string[] = [];
|
||||
for (const info of coreCatalogEntries()) {
|
||||
const resolved = resolveForHost(info, levels, hostId);
|
||||
let own: boolean;
|
||||
if (locked.has(info.key)) {
|
||||
own = before?.[CORE_NAMESPACE]?.includes(info.key) ?? true;
|
||||
if (own) overridden.push(info.key);
|
||||
continue;
|
||||
}
|
||||
if (sent?.[CORE_NAMESPACE]) {
|
||||
own = sent[CORE_NAMESPACE].includes(info.key);
|
||||
} else if (stored && before?.[CORE_NAMESPACE]) {
|
||||
own =
|
||||
before[CORE_NAMESPACE].includes(info.key) ||
|
||||
!defaultValuesEqual(
|
||||
info.normalize(incoming(info.key)),
|
||||
info.normalize(previous(info.key)),
|
||||
);
|
||||
} else if (!stored && leftOut(body, info.key)) {
|
||||
own = false;
|
||||
} else {
|
||||
own = isOwnValue(info, incoming(info.key), resolved);
|
||||
}
|
||||
|
||||
if (own || !resolved) {
|
||||
if (own) overridden.push(info.key);
|
||||
continue;
|
||||
}
|
||||
Object.assign(
|
||||
columns,
|
||||
writeCoreDefault(
|
||||
info.key,
|
||||
resolved.value,
|
||||
{ ...storedColumns, ...columns },
|
||||
storedColumns.sshOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const overrides: DefaultOverrides = { ...(before ?? {}) };
|
||||
if (sent) {
|
||||
for (const [namespace, keys] of Object.entries(sent)) {
|
||||
if (namespace !== CORE_NAMESPACE) overrides[namespace] = keys;
|
||||
}
|
||||
}
|
||||
overrides[CORE_NAMESPACE] = overridden.sort();
|
||||
return overrides;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Deletes a credential the user owns: hosts using it fall back to password
|
||||
* auth with no secret, and shares are re-snapshotted. Shared by the delete
|
||||
* route and sync. Null when it does not exist or is not the user's.
|
||||
*/
|
||||
export async function deleteOwnedCredential(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<{ name: string | null } | null> {
|
||||
const credential =
|
||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (!credential) return null;
|
||||
|
||||
const hostsUsingCredential =
|
||||
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
await createCurrentHostRepository().updateManyForUser(
|
||||
userId,
|
||||
hostsUsingCredential.map((host) => host.id),
|
||||
{
|
||||
credentialId: null,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
authType: "password",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
|
||||
await sharedSecretsManager.deleteForCredential(credentialId);
|
||||
|
||||
await createCurrentCredentialRepository().deleteForUser(userId, credentialId);
|
||||
|
||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||
// auth the host still has (or lose the stale credential copy).
|
||||
for (const host of hostsUsingCredential) {
|
||||
await sharedSecretsManager.resyncHost(host.id);
|
||||
}
|
||||
return { name: (credential.name as string | null) ?? null };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { emitInternalEvent } from "./internal-events.js";
|
||||
|
||||
export interface DeletedHost {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a host the user owns, with everything that hangs off it. Shared by
|
||||
* the host editor's delete route and ctx.hosts.delete, so a plugin deleting a
|
||||
* host leaves exactly what a person deleting it would. Null when the host
|
||||
* does not exist or is not the user's.
|
||||
*/
|
||||
export async function deleteOwnedHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<DeletedHost | null> {
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host) return null;
|
||||
|
||||
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
|
||||
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
|
||||
await createCurrentHostRepository().deleteForUser(userId, hostId);
|
||||
|
||||
const name = host.name ?? host.ip;
|
||||
emitInternalEvent("host_deleted", userId, hostId, { name });
|
||||
|
||||
try {
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId, userId });
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to publish host deletion event", {
|
||||
operation: "host_delete",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return { id: hostId, name };
|
||||
}
|
||||
@@ -1,839 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import { systemLogger } from "../../utils/logger.js";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../host-identity.js";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
import {
|
||||
asObject,
|
||||
asString,
|
||||
MAX_WS_MESSAGE_BYTES,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../utils/ws-message.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: import("ssh2").ClientChannel | null;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
hostId?: number;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 30009,
|
||||
maxPayload: MAX_WS_MESSAGE_BYTES,
|
||||
});
|
||||
|
||||
wss.on("error", (error) => {
|
||||
sshLogger.error("Docker console WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
containerId: string,
|
||||
): Promise<string> {
|
||||
const shells = ["bash", "sh", "ash"];
|
||||
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.client.exec(
|
||||
containerCommand(
|
||||
session.containerRuntime,
|
||||
`exec ${containerId} which ${shell}`,
|
||||
),
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
|
||||
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = resolvedJumpHost as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
} catch (e) {
|
||||
sshLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: jumpHost.id,
|
||||
});
|
||||
jumpHost.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: jumpHost.password,
|
||||
sshKey: jumpHost.key,
|
||||
keyPassword: jumpHost.keyPassword,
|
||||
authType: jumpHost.authType,
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
jumpHost.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credential) {
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
jumpHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentClient!.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHost.ip,
|
||||
jumpHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
config.sock = stream;
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
currentClient = client;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
let payload;
|
||||
try {
|
||||
payload = await authManagerInstance.verifyJWTToken(token);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Docker console JWT verification failed", {
|
||||
operation: "docker_console_auth_error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(wsPingInterval);
|
||||
if (!sshSession) return;
|
||||
sshSession.stream?.end();
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
sshSession = null;
|
||||
};
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = parseWsMessage(data);
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const connectData = asObject(message.data);
|
||||
const hostConfig = asObject(
|
||||
connectData.hostConfig,
|
||||
) as unknown as SSHHost;
|
||||
const containerId = asString(connectData.containerId);
|
||||
const shell = asString(connectData.shell) || undefined;
|
||||
const cols = toTerminalDimension(connectData.cols) || 80;
|
||||
const rows = toTerminalDimension(connectData.rows) || 24;
|
||||
|
||||
const hostId = hostConfig?.id;
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../host-resolver.js");
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const hostSyncId = hostConfig?.syncId;
|
||||
const resolvedHost = hostSyncId
|
||||
? await resolveHostBySyncId(hostSyncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: hostSyncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The connection below dials resolvedHost.ip outright, so if this
|
||||
// server has a different machine under the id the client sent, the
|
||||
// console opens on that machine's Docker daemon instead.
|
||||
if (
|
||||
!hostSyncId &&
|
||||
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
|
||||
) {
|
||||
sshLogger.error(
|
||||
"Refusing Docker console: host id resolves to a different address here",
|
||||
undefined,
|
||||
{
|
||||
operation: "docker_console_host_id_mismatch",
|
||||
hostId,
|
||||
userId,
|
||||
clientIp: hostConfig?.ip,
|
||||
resolvedIp: resolvedHost.ip,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
|
||||
port: resolvedHost.port || 22,
|
||||
username: resolvedHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (resolvedHost.authType === "password" && resolvedHost.password) {
|
||||
config.password = resolvedHost.password;
|
||||
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
|
||||
const cleanKey = resolvedHost.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedHost.keyPassword) {
|
||||
config.passphrase = resolvedHost.keyPassword;
|
||||
}
|
||||
} else if (resolvedHost.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
resolvedHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: result.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
resolvedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
const stream = await new Promise<import("ssh2").ClientChannel>(
|
||||
(resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
resolvedHost.ip,
|
||||
resolvedHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
config.sock = stream;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
const { runtime: containerRuntime } = getContainerRuntimeConfig(
|
||||
resolvedHost.dockerConfig,
|
||||
);
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: resolvedHost.id,
|
||||
containerRuntime,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
let shellToUse = shell || "bash";
|
||||
|
||||
if (shell) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.exec(
|
||||
containerCommand(
|
||||
containerRuntime,
|
||||
`exec ${containerId} which ${shell}`,
|
||||
),
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not available`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
sshLogger.warn(
|
||||
`Requested shell ${shell} not found, detecting available shell`,
|
||||
{
|
||||
operation: "shell_validation",
|
||||
sessionId,
|
||||
containerId,
|
||||
requestedShell: shell,
|
||||
},
|
||||
);
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
} else {
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
|
||||
sshSession.shell = shellToUse;
|
||||
|
||||
const execCommand = containerCommand(
|
||||
containerRuntime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
);
|
||||
sshLogger.info("Attaching to Docker container", {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Failed to create docker exec", err, {
|
||||
operation: "docker_exec",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshSession!.stream = stream;
|
||||
sshLogger.success("Docker container attached", {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
// Buffers incomplete multi-byte UTF-8 sequences across chunk
|
||||
// boundaries so box-drawing/special characters don't get
|
||||
// corrupted when a character is split across TCP packets.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const text = decoder.write(data);
|
||||
if (!text) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Console session ended",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (sshSession) {
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: shell && shell !== shellToUse,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to connect to container", error, {
|
||||
operation: "console_connect",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Failed to connect to container",
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const input = asString(message.data);
|
||||
if (input) sshSession.stream.write(input);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const dimensions = asObject(message.data);
|
||||
const cols = toTerminalDimension(dimensions.cols);
|
||||
const rows = toTerminalDimension(dimensions.rows);
|
||||
if (cols && rows)
|
||||
sshSession.stream.setWindow(rows, cols, rows, cols);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ping": {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket message error", error, {
|
||||
operation: "ws_message",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "An error occurred"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
sshLogger.info("Docker console disconnected", {
|
||||
operation: "docker_console_disconnect",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: sshSession?.hostId,
|
||||
containerId: sshSession?.containerId,
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
activeSessions.forEach((session) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
wss.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,43 +0,0 @@
|
||||
export type ContainerRuntime = "docker" | "podman";
|
||||
|
||||
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||
return value === "podman" ? "podman" : "docker";
|
||||
}
|
||||
|
||||
export function getContainerRuntimeConfig(raw: unknown): {
|
||||
runtime: ContainerRuntime;
|
||||
} {
|
||||
if (!raw) {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
let config: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!config || typeof config !== "object") {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: normalizeContainerRuntime(
|
||||
(config as Record<string, unknown>).runtime,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function containerCommand(
|
||||
runtime: ContainerRuntime | undefined,
|
||||
args: string,
|
||||
): string {
|
||||
return `${normalizeContainerRuntime(runtime)} ${args}`;
|
||||
}
|
||||
|
||||
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
||||
return runtime === "podman" ? "Podman" : "Docker";
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||
import {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
cleanupSession,
|
||||
executeDockerCommand,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
getRequestUserId,
|
||||
registerDockerSshRoutes,
|
||||
} from "./routes.js";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const PORT = 30007;
|
||||
|
||||
listenOnServicePort({
|
||||
app,
|
||||
port: PORT,
|
||||
logger: sshLogger,
|
||||
serviceName: "docker",
|
||||
onListening: async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
Object.keys(sshSessions).forEach((sessionId) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
Object.keys(sshSessions).forEach((sessionId) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
process.exit(0);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,171 +0,0 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
isWindows?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export const sshSessions: Record<string, SSHSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
|
||||
const session = pendingTOTPSessions[sessionId];
|
||||
if (now - session.createdAt > 180000) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
}
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
export function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
sshLogger.warn(
|
||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
session.timeout = setTimeout(() => {
|
||||
cleanupSession(sessionId);
|
||||
}, SESSION_IDLE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDockerCommand(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sessionId?: string,
|
||||
userId?: string,
|
||||
hostId?: number,
|
||||
): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
sshLogger.info("Executing Docker command", {
|
||||
operation: "docker_command_exec",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Docker command execution error", err, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
sshLogger.error("Docker command failed", undefined, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
exitCode: code,
|
||||
stderr,
|
||||
});
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
sshLogger.success("Docker command completed", {
|
||||
operation: "docker_command_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
sshLogger.error("Docker command stream error", streamErr, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
reject(streamErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { createCurrentSecretSourceRepository } from "../database/repositories/factory.js";
|
||||
import type { SecretSourceRecord } from "../database/repositories/secret-source-repository.js";
|
||||
import {
|
||||
isSecretReference,
|
||||
parseSecretReference,
|
||||
resolveConnectReference,
|
||||
} from "../utils/onepassword-connect.js";
|
||||
import { readSecretSourcePrivateAllowlist } from "../utils/secret-source-egress.js";
|
||||
import { requireSecretResolver } from "./connect/secret-resolver-registry.js";
|
||||
|
||||
/**
|
||||
* Expands "op://vault/item/field" references in a resolved host's secret
|
||||
* fields into the actual secrets, fetched from the user's secret source.
|
||||
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
|
||||
* Connect, and so on) in a resolved host's secret fields into the actual
|
||||
* secrets, resolved by whichever plugin registered that scheme through
|
||||
* ctx.credentials.registerSecretResolver.
|
||||
*
|
||||
* Runs once per host resolution, at the single point where every subsystem
|
||||
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
|
||||
@@ -19,17 +14,16 @@ import { readSecretSourcePrivateAllowlist } from "../utils/secret-source-egress.
|
||||
* cached briefly in memory; a rotated secret shows up within CACHE_TTL_MS.
|
||||
*/
|
||||
|
||||
export const SECRET_FIELDS = [
|
||||
const SECRET_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
] as const;
|
||||
|
||||
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const cache = new Map<string, { value: string; expiresAt: number }>();
|
||||
|
||||
@@ -38,37 +32,27 @@ export function clearExternalSecretCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function isSecretReference(value: unknown): value is string {
|
||||
return typeof value === "string" && REFERENCE_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
function referenceScheme(reference: string): string {
|
||||
const match = REFERENCE_PATTERN.exec(reference.trim());
|
||||
return (match?.[1] ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
export type SecretResolver = (
|
||||
source: SecretSourceRecord,
|
||||
userId: string,
|
||||
reference: string,
|
||||
) => Promise<string>;
|
||||
|
||||
async function defaultResolver(
|
||||
source: SecretSourceRecord,
|
||||
userId: string,
|
||||
reference: string,
|
||||
): Promise<string> {
|
||||
const ref = parseSecretReference(reference);
|
||||
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
|
||||
const repository = createCurrentSecretSourceRepository();
|
||||
return resolveConnectReference(
|
||||
{
|
||||
baseUrl: source.baseUrl,
|
||||
token: repository.decryptToken(source),
|
||||
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
|
||||
},
|
||||
ref,
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's own source first, else a shared one. */
|
||||
export async function pickSecretSource(
|
||||
userId: string,
|
||||
): Promise<SecretSourceRecord | null> {
|
||||
const sources =
|
||||
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
|
||||
return (
|
||||
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
|
||||
);
|
||||
const scheme = referenceScheme(reference);
|
||||
const resolver = requireSecretResolver(scheme);
|
||||
return resolver.resolve(userId, reference);
|
||||
}
|
||||
|
||||
export async function resolveSecretReference(
|
||||
@@ -76,22 +60,15 @@ export async function resolveSecretReference(
|
||||
reference: string,
|
||||
deps: {
|
||||
resolver?: SecretResolver;
|
||||
pickSource?: (userId: string) => Promise<SecretSourceRecord | null>;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const source = await (deps.pickSource ?? pickSecretSource)(userId);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"This host uses a secret reference but no secret source is configured",
|
||||
);
|
||||
}
|
||||
const cacheKey = `${source.id}:${reference.trim()}`;
|
||||
const cacheKey = `${userId}:${reference.trim()}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now()) return cached.value;
|
||||
|
||||
const value = await (deps.resolver ?? defaultResolver)(source, reference);
|
||||
const value = await (deps.resolver ?? defaultResolver)(userId, reference);
|
||||
cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS });
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { execChannel, type SSHSession } from "./session.js";
|
||||
|
||||
type FileActionRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileActionRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileActionRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/copyItem:
|
||||
* post:
|
||||
* summary: Copy a file or directory
|
||||
* description: Copies a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* sourcePath:
|
||||
* type: string
|
||||
* targetDir:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item copied successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to copy item.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !sourcePath || !targetDir) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Copy operation timed out",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Copy operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorData = "";
|
||||
let stdoutData = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
errorData += output;
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (code !== 0) {
|
||||
const fullErrorInfo =
|
||||
errorData || stdoutData || "No error message available";
|
||||
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
|
||||
operation: "file_copy_failed",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
exitCode: code,
|
||||
errorData,
|
||||
stdoutData,
|
||||
fullErrorInfo,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Copy failed: ${fullErrorInfo}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Copy failed: ${fullErrorInfo}`,
|
||||
},
|
||||
debug: {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
exitCode: code,
|
||||
command: copyCommand,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const copySuccessful =
|
||||
stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
|
||||
if (copySuccessful) {
|
||||
fileLogger.success("Item copied successfully", {
|
||||
operation: "file_copy",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item copied successfully",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Successfully copied to: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fileLogger.warn("Copy completed but without success confirmation", {
|
||||
operation: "file_copy_uncertain",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
code,
|
||||
stdoutData: stdoutData.substring(0, 200),
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Copy may have completed",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "warning",
|
||||
message: `Copy completed but verification uncertain for: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/executeFile:
|
||||
* post:
|
||||
* summary: Execute a file
|
||||
* description: Executes a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* filePath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File execution result.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 500:
|
||||
* description: Failed to execute file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for executeFile",
|
||||
{
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to check file executability" });
|
||||
}
|
||||
|
||||
let checkResult = "";
|
||||
checkStream.on("data", (data) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
|
||||
checkStream.on("close", () => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
execChannel(sshConn, executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
fileLogger.info("File execution completed", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
exitCode: actualExitCode,
|
||||
outputLength: cleanOutput.length,
|
||||
errorLength: errorOutput.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
output: cleanOutput,
|
||||
error: errorOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Execution stream error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/changePermissions:
|
||||
* post:
|
||||
* summary: Change file permissions
|
||||
* description: Changes the permissions of a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* permissions:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Permissions changed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 408:
|
||||
* description: Permission change timed out.
|
||||
* 500:
|
||||
* description: Failed to change permissions.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
const { sessionId, path, permissions } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for changePermissions",
|
||||
{
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: "Valid permissions required (e.g., 755, 644)",
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
fileLogger.info("Changing file permissions", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
fileLogger.error("changePermissions command timeout", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error("chmod command failed", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while changing permissions" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Client as SSHClient, ConnectConfig } from "ssh2";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Attaches a user-managed CA-signed certificate to an SFTP connection, if the
|
||||
* host carries one.
|
||||
*
|
||||
* The terminal has always done this. The file manager never did: it imports
|
||||
* and calls `setupOPKSSHCertAuth`, but `setupCACertAuth` — the sibling helper
|
||||
* for user-managed `-cert.pub` files — had no call site here at all. A host
|
||||
* whose key is paired with a CA-signed certificate therefore authenticated in
|
||||
* a terminal and failed in the file manager, while OPKSSH certificates worked
|
||||
* in both. That asymmetry is the whole bug.
|
||||
*
|
||||
* `cert_public_key` is a column on `ssh_data` but is not part of the shared
|
||||
* `Host` type, so callers pass whichever record they hold and it is read off
|
||||
* structurally — the same way the terminal reads it.
|
||||
*
|
||||
* A certificate that cannot be applied is logged and skipped rather than
|
||||
* failing the connection: the private key alone may still be accepted, which
|
||||
* is exactly what happened before any of this was wired up.
|
||||
*/
|
||||
export async function applyCACertIfPresent(
|
||||
config: Record<string, unknown>,
|
||||
client: SSHClient,
|
||||
privateKey: Buffer | string,
|
||||
source: { certPublicKey?: string | null },
|
||||
username: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const certPublicKey = source.certPublicKey;
|
||||
if (!certPublicKey || !certPublicKey.trim()) return;
|
||||
|
||||
try {
|
||||
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
|
||||
await setupCACertAuth(
|
||||
config as ConnectConfig,
|
||||
client,
|
||||
privateKey,
|
||||
certPublicKey,
|
||||
username,
|
||||
passphrase,
|
||||
);
|
||||
} catch (certError) {
|
||||
fileLogger.warn("CA certificate setup failed, continuing with key only", {
|
||||
operation: "sftp_ca_cert_auth_failed",
|
||||
error: getErrorMessage(certError),
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,72 +0,0 @@
|
||||
export interface DirectTransferEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
|
||||
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
|
||||
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
|
||||
}
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
export function buildDirectSshCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
return [
|
||||
"ssh",
|
||||
"-o BatchMode=yes",
|
||||
"-o ConnectTimeout=5",
|
||||
"-o StrictHostKeyChecking=yes",
|
||||
`-p ${endpoint.port}`,
|
||||
quoteShell(target),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildDirectProbeCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
|
||||
}
|
||||
|
||||
export function buildDirectRsyncCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
sourcePaths: string[],
|
||||
destPath: string,
|
||||
destIsDirectory: boolean,
|
||||
): string {
|
||||
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
|
||||
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
const targetPath = destIsDirectory
|
||||
? `${destPath.replace(/\/+$/, "") || "/"}/`
|
||||
: destPath;
|
||||
const sources = sourcePaths.map(quoteShell).join(" ");
|
||||
const target = quoteShell(`${targetHost}:${targetPath}`);
|
||||
|
||||
return [
|
||||
"rsync -a --partial --append-verify --protect-args --info=progress2",
|
||||
`-e ${quoteShell(sshTransport)}`,
|
||||
"--",
|
||||
sources,
|
||||
target,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function shouldUseDirectTransfer(
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
|
||||
): boolean {
|
||||
if (directMs <= 0 || relayMs <= 0) return false;
|
||||
return directMs <= relayMs * (1 - minImprovement);
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { getMimeType } from "./utils.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
function escapeSingleQuotes(path: string): string {
|
||||
return path.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
function downloadViaExec(
|
||||
session: SSHSession,
|
||||
filePath: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(session, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
const chunks: Buffer[] = [];
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(stderr.trim() || `cat exited with code ${code}`),
|
||||
);
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerFileDownloadRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileDownloadRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFile:
|
||||
* post:
|
||||
* summary: Download a file
|
||||
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file content.
|
||||
* 400:
|
||||
* description: Missing required parameters or file too large.
|
||||
* 500:
|
||||
* description: Failed to download file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const downloadStartTime = Date.now();
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
fileLogger.warn("Missing download parameters", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
hasFilePath: !!filePath,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
fileLogger.info("File download started", {
|
||||
operation: "file_download_start",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.warn("SSH session not found or not connected for download", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
isConnected: sshConn?.isConnected,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info(
|
||||
"Downloading file via legacy exec/cat (SCP legacy mode)",
|
||||
{
|
||||
operation: "file_download_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await downloadViaExec(sshConn, filePath);
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed (legacy mode)", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: data.length,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
return res.json({
|
||||
content: data.toString("base64"),
|
||||
fileName,
|
||||
size: data.length,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
} catch (err) {
|
||||
fileLogger.error("Legacy exec/cat download failed:", err);
|
||||
return res.status(500).json({
|
||||
error: `Failed to download file: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
fileLogger.error("File stat failed for download:", statErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Cannot access file: ${statErr.message}` });
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
fileLogger.warn("Attempted to download non-file", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
filePath,
|
||||
isFile: stats.isFile(),
|
||||
isDirectory: stats.isDirectory(),
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot download directories or special files" });
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
fileLogger.warn("File too large for download", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
filePath,
|
||||
fileSize: stats.size,
|
||||
maxSize: MAX_FILE_SIZE,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
|
||||
});
|
||||
}
|
||||
|
||||
sftp.readFile(filePath, (readErr, data) => {
|
||||
if (readErr) {
|
||||
fileLogger.error("File read failed for download:", readErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Failed to read file: ${readErr.message}` });
|
||||
}
|
||||
|
||||
const base64Content = data.toString("base64");
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: stats.size,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
|
||||
res.json({
|
||||
content: base64Content,
|
||||
fileName: fileName,
|
||||
size: stats.size,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
fileLogger.error("SFTP connection failed for download:", err);
|
||||
return res.status(500).json({ error: "SFTP connection failed" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFileStream:
|
||||
* post:
|
||||
* summary: Stream-download a file
|
||||
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Binary file stream.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Download failed.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
|
||||
operation: "file_download_stream_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${streamErr.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
(resolve, reject) => {
|
||||
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
},
|
||||
);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
readStream.pipe(res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,467 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
import {
|
||||
formatMtime,
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
parseLsDateToTimestamp,
|
||||
} from "./utils.js";
|
||||
|
||||
type FileListingRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
activeListRequests: Record<string, boolean>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileListingRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
activeListRequests,
|
||||
verifySessionOwnership,
|
||||
}: FileListingRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/listFiles:
|
||||
* get:
|
||||
* summary: List files in a directory
|
||||
* description: Lists the files and directories in a given path on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of files and directories.
|
||||
* 400:
|
||||
* description: Session ID is required or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to list files.
|
||||
*/
|
||||
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = (req.query.path as string) || "/";
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
// Drop concurrent requests for the same session+path — each would open
|
||||
// a new SSH channel and can exceed the server's per-connection channel limit.
|
||||
const listKey = `${sessionId}:${sshPath}`;
|
||||
if (activeListRequests[listKey]) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "List request already in progress" });
|
||||
}
|
||||
activeListRequests[listKey] = true;
|
||||
res.on("finish", () => {
|
||||
delete activeListRequests[listKey];
|
||||
});
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: sshPath,
|
||||
});
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.readdir(sshPath, (readdirErr, list) => {
|
||||
if (readdirErr) {
|
||||
fileLogger.warn(
|
||||
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
|
||||
const symlinks: Array<{ index: number; path: string }> = [];
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const entry of list) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
|
||||
const attrs = entry.attrs;
|
||||
const permissions = modeToPermissions(attrs.mode);
|
||||
const isDirectory = attrs.isDirectory();
|
||||
const isLink = attrs.isSymbolicLink();
|
||||
|
||||
const fileEntry = {
|
||||
name: entry.filename,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : attrs.size,
|
||||
modified: formatMtime(attrs.mtime),
|
||||
modifiedTimestamp: attrs.mtime,
|
||||
permissions,
|
||||
owner: String(attrs.uid),
|
||||
group: String(attrs.gid),
|
||||
linkTarget: undefined as string | undefined,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, entry.filename)
|
||||
: false,
|
||||
};
|
||||
|
||||
if (isLink) {
|
||||
symlinks.push({ index: files.length, path: fileEntry.path });
|
||||
}
|
||||
|
||||
files.push(fileEntry);
|
||||
}
|
||||
|
||||
if (symlinks.length === 0) {
|
||||
sshConn.activeOperations--;
|
||||
return res.json({ files, path: sshPath });
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let responded = false;
|
||||
|
||||
const sendResponse = () => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
sshConn.activeOperations--;
|
||||
res.json({ files, path: sshPath });
|
||||
};
|
||||
|
||||
const readlinkTimeout = setTimeout(sendResponse, 5000);
|
||||
|
||||
for (const link of symlinks) {
|
||||
sftp.readlink(link.path, (linkErr, target) => {
|
||||
resolved++;
|
||||
if (!linkErr && target) {
|
||||
files[link.index].linkTarget = target;
|
||||
}
|
||||
if (resolved === symlinks.length) {
|
||||
clearTimeout(readlinkTimeout);
|
||||
sendResponse();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileLogger.warn(
|
||||
`SFTP failed for listFiles, trying fallback: ${err.message}`,
|
||||
);
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
sshConn.isConnected = false;
|
||||
sshConn.sftp = undefined;
|
||||
}
|
||||
tryFallbackMethod();
|
||||
});
|
||||
} catch (sftpErr: unknown) {
|
||||
const errMsg = getErrorMessage(sftpErr);
|
||||
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "SSH session disconnected", disconnected: true });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
execChannel(
|
||||
sshConn,
|
||||
`command ls -la --color=never '${escapedPath}'`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const isPermissionDenied =
|
||||
errorData.toLowerCase().includes("permission denied") ||
|
||||
errorData.toLowerCase().includes("access denied");
|
||||
|
||||
if (isPermissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
fileLogger.info(
|
||||
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
|
||||
);
|
||||
tryWithSudo();
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.warn(
|
||||
`Permission denied for listFiles, sudo required: ${sshPath}`,
|
||||
);
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot access ${sshPath}`,
|
||||
needsSudo: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Command failed: ${errorData}` });
|
||||
}
|
||||
sshConn.activeOperations--;
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tryWithSudo = () => {
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
|
||||
|
||||
execChannel(sshConn, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH sudo listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
sshConn.activeOperations--;
|
||||
|
||||
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
|
||||
|
||||
if (
|
||||
data.toLowerCase().includes("sorry, try again") ||
|
||||
data.toLowerCase().includes("incorrect password") ||
|
||||
errorData.toLowerCase().includes("sorry, try again")
|
||||
) {
|
||||
sshConn.sudoPassword = undefined;
|
||||
return res.status(403).json({
|
||||
error: "Sudo authentication failed. Please try again.",
|
||||
needsSudo: true,
|
||||
sudoFailed: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (code !== 0 && !data.trim()) {
|
||||
fileLogger.error(
|
||||
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Sudo command failed: ${errorData || data}` });
|
||||
}
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
});
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
trySFTP();
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
|
||||
|
||||
export interface DeleteCommand {
|
||||
command: string;
|
||||
commandWithSuccess: string;
|
||||
}
|
||||
|
||||
function quotePosixPath(path: string): string {
|
||||
return `'${path.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function buildDeleteCommand(
|
||||
itemPath: string,
|
||||
isDirectory: boolean,
|
||||
): DeleteCommand {
|
||||
if (isWindowsSftpPath(itemPath)) {
|
||||
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
|
||||
const command = isDirectory
|
||||
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
|
||||
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
};
|
||||
}
|
||||
|
||||
const path = quotePosixPath(itemPath);
|
||||
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command} && echo "SUCCESS"`,
|
||||
};
|
||||
}
|
||||
@@ -1,913 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
execChannel,
|
||||
execWithSudo,
|
||||
getSessionSftp,
|
||||
type SSHSession,
|
||||
} from "./session.js";
|
||||
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
|
||||
import { buildDeleteCommand } from "./operation-commands.js";
|
||||
import {
|
||||
emptyTrash,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "./trash-service.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileOperationRoutes(
|
||||
app: Express,
|
||||
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
|
||||
): void {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const getTrashRetentionDays = () => {
|
||||
try {
|
||||
const value = Number(
|
||||
getCurrentSettingValue("file_manager_trash_retention_days"),
|
||||
);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
};
|
||||
|
||||
async function ownedSession(
|
||||
req: AuthenticatedRequest,
|
||||
res: import("express").Response,
|
||||
) {
|
||||
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
|
||||
const session = sshSessions[sessionId];
|
||||
if (!sessionId || !session?.isConnected) {
|
||||
res.status(400).json({ error: "SSH connection not established" });
|
||||
return null;
|
||||
}
|
||||
if (!verifySessionOwnership(session, req.userId)) {
|
||||
res.status(403).json({ error: "Session access denied" });
|
||||
return null;
|
||||
}
|
||||
session.lastActive = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
app.get("/ssh/file_manager/ssh/trash", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({
|
||||
items: await listTrash(
|
||||
await getSessionSftp(session),
|
||||
getTrashRetentionDays(),
|
||||
),
|
||||
retentionDays: getTrashRetentionDays(),
|
||||
canManageRetention: await permissionManager.isAdmin(
|
||||
(req as AuthenticatedRequest).userId,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to list trash", error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/trash/:id/restore", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({
|
||||
item: await restoreTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
res
|
||||
.status(message.includes("already exists") ? 409 : 500)
|
||||
.json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/ssh/file_manager/ssh/trash/:id", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
await permanentlyDeleteTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/ssh/file_manager/ssh/trash", async (req, res) => {
|
||||
const session = await ownedSession(req as AuthenticatedRequest, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/ssh/file_manager/ssh/trash-retention", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const retentionDays = Number(req.body?.retentionDays);
|
||||
if (
|
||||
!Number.isInteger(retentionDays) ||
|
||||
retentionDays < 1 ||
|
||||
retentionDays > 3650
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Retention must be between 1 and 3650 days" });
|
||||
}
|
||||
await createCurrentSettingsRepository().upsert(
|
||||
"file_manager_trash_retention_days",
|
||||
String(retentionDays),
|
||||
);
|
||||
return res.json({ retentionDays });
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFile:
|
||||
* post:
|
||||
* summary: Create a file
|
||||
* description: Creates an empty file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* fileName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath || !fileName) {
|
||||
return res.status(400).json({ error: "File path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = filePath.endsWith("/")
|
||||
? filePath + fileName
|
||||
: filePath + "/" + fileName;
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH createFile error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied creating file: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `File creation failed: ${errorData}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH createFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFolder:
|
||||
* post:
|
||||
* summary: Create a folder
|
||||
* description: Creates a new folder on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* folderName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create folder.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
const { sessionId, path: folderPath, folderName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!folderPath || !folderName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Folder path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = folderPath.endsWith("/")
|
||||
? folderPath + folderName
|
||||
: folderPath + "/" + folderName;
|
||||
fileLogger.info("Creating directory", {
|
||||
operation: "file_mkdir",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
try {
|
||||
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
|
||||
res.json({
|
||||
message: "Folder created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("SFTP createFolder failed", error);
|
||||
const code = (error as { code?: string | number }).code;
|
||||
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/deleteItem:
|
||||
* delete:
|
||||
* summary: Delete a file or directory
|
||||
* description: Deletes a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* isDirectory:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to delete item.
|
||||
*/
|
||||
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!itemPath) {
|
||||
return res.status(400).json({ error: "Item path is required" });
|
||||
}
|
||||
|
||||
fileLogger.info("Deleting item", {
|
||||
operation: "file_delete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
type: isDirectory ? "directory" : "file",
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
if (!permanent) {
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
await listTrash(sftp, getTrashRetentionDays());
|
||||
const item = await moveToTrash(sftp, itemPath);
|
||||
fileLogger.success("Item moved to trash", {
|
||||
operation: "file_trash_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
trashId: item.id,
|
||||
});
|
||||
return res.json({
|
||||
message: "Item moved to trash",
|
||||
path: itemPath,
|
||||
trashItem: item,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to move item to trash", error, {
|
||||
operation: "file_trash_failed",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
return res.status(409).json({
|
||||
error: (error as Error).message,
|
||||
trashUnavailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
||||
itemPath,
|
||||
Boolean(isDirectory),
|
||||
);
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (useSudo && sshConn.sudoPassword) {
|
||||
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
|
||||
(result) => {
|
||||
if (
|
||||
result.code === 0 ||
|
||||
(!result.stderr.includes("Permission denied") &&
|
||||
!result.stdout.includes("Permission denied"))
|
||||
) {
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${result.stderr || result.stdout}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH deleteItem error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
let permissionDenied = false;
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
permissionDenied = true;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (permissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
executeDelete(true).then(resolve);
|
||||
return;
|
||||
}
|
||||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||
res.status(403).json({
|
||||
error: `Permission denied: Cannot delete ${itemPath}.`,
|
||||
needsSudo: true,
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Item deleted successfully", {
|
||||
operation: "file_delete_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const detail =
|
||||
errorData.trim() ||
|
||||
outputData.trim() ||
|
||||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
||||
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
|
||||
operation: "file_delete_failed",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${detail}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await executeDelete(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/renameItem:
|
||||
* put:
|
||||
* summary: Rename a file or directory
|
||||
* description: Renames a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item renamed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to rename item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
|
||||
const newPath = oldDir + newName;
|
||||
fileLogger.info("Renaming item", {
|
||||
operation: "file_rename",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH renameItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied renaming: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Rename failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH renameItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/moveItem:
|
||||
* put:
|
||||
* summary: Move a file or directory
|
||||
* description: Moves a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item moved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 408:
|
||||
* description: Move operation timed out.
|
||||
* 500:
|
||||
* description: Failed to move item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new path are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
error: "Move operation timed out. SSH connection may be unstable.",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Move operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied moving: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Move failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
|
||||
// Serializes SSH channel open requests so only one channel negotiation is
|
||||
// in-flight at a time per session. Once the channel is established the slot
|
||||
// is released immediately so the next open can proceed; the channels
|
||||
// themselves remain open concurrently.
|
||||
export class ChannelOpenSerializer {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(action: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(
|
||||
() => action(),
|
||||
() => action(),
|
||||
);
|
||||
this.tail = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
|
||||
channelOpener: ChannelOpenSerializer;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
scpLegacy?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
}
|
||||
|
||||
export function execWithSudo(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
}));
|
||||
}
|
||||
|
||||
export function execWithSudoBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
|
||||
|
||||
execChannel(session, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (settled) return;
|
||||
let stdout = Buffer.concat(stdoutChunks);
|
||||
const sudoPromptMatch = stdout
|
||||
.toString("utf8", 0, Math.min(stdout.length, 256))
|
||||
.match(/^\[sudo\] password for .+?:\s*/);
|
||||
if (sudoPromptMatch) {
|
||||
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
|
||||
}
|
||||
settled = true;
|
||||
resolve({ stdout, stderr, code: code || 0 });
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
|
||||
stream.write(`${sudoPassword}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function execBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
execChannel(session, command, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => finish(code || 0));
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = stderr || streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionSftp(
|
||||
session: SSHSession,
|
||||
): Promise<import("ssh2").SFTPWrapper> {
|
||||
if (session.sftp) {
|
||||
return Promise.resolve(session.sftp);
|
||||
}
|
||||
|
||||
if (session.sftpPending) {
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
|
||||
session.channelOpener.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
|
||||
session.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
session.sftp = sftp;
|
||||
sftp.on("error", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
sftp.on("close", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
resolve(sftp);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
session.sftpPending = openOnce()
|
||||
.catch((err: Error) => {
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
|
||||
setTimeout(() => openOnce().then(resolve, reject), 500),
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
session.sftpPending = undefined;
|
||||
});
|
||||
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
export function execChannel(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
stream: import("ssh2").ClientChannel,
|
||||
) => void,
|
||||
): void {
|
||||
session.channelOpener
|
||||
.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(
|
||||
(stream) => callback(undefined, stream),
|
||||
(err: Error) => callback(err, undefined as never),
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export const SFTP_OPEN_READ = 0x00000001;
|
||||
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||
|
||||
export function promisifySftpStat(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpUnlink(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpRmdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpMkdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(path, { mode }, (err) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpChmod(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.chmod(path, mode, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpReaddir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.readdir(path, (err, list) => {
|
||||
if (err) reject(err);
|
||||
else resolve(list);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpOpen(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
flags: number,
|
||||
mode: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.open(path, flags, mode, (err, handle) => {
|
||||
if (err) reject(err);
|
||||
else resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpClose(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.close(handle, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpFstat(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.fstat(handle, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,52 +0,0 @@
|
||||
export class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferStalledError extends Error {
|
||||
readonly byteOffset?: number;
|
||||
readonly segmentIndex?: number;
|
||||
|
||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||
this.name = "TransferStalledError";
|
||||
this.byteOffset = byteOffset;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferConnectionLostError extends Error {
|
||||
constructor(message = "Transfer SSH connection lost") {
|
||||
super(message);
|
||||
this.name = "TransferConnectionLostError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("no response from server") ||
|
||||
msg.includes("connection lost") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("etimedout") ||
|
||||
msg.includes("socket hang up") ||
|
||||
msg.includes("protocol error") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("channel open failure")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableTransferError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof TransferStalledError ||
|
||||
err instanceof TransferConnectionLostError ||
|
||||
isRecoverableTransferConnectionError(err)
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { networkInterfaces } from "os";
|
||||
import { normalizeSftpPath } from "../transfer-paths.js";
|
||||
|
||||
let cachedLocalAddresses: Set<string> | null = null;
|
||||
|
||||
export function normalizeHostAddress(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed.split(":")[0] ?? trimmed;
|
||||
}
|
||||
|
||||
function getLocalAddresses(): Set<string> {
|
||||
if (cachedLocalAddresses) return cachedLocalAddresses;
|
||||
|
||||
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
if (!ifaces) continue;
|
||||
for (const iface of ifaces) {
|
||||
if (!iface.internal && iface.family === "IPv4") {
|
||||
addresses.add(iface.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedLocalAddresses = addresses;
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function isLocalSshEndpoint(ip?: string): boolean {
|
||||
if (!ip) return false;
|
||||
const bare = normalizeHostAddress(ip);
|
||||
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
||||
return true;
|
||||
}
|
||||
return getLocalAddresses().has(bare);
|
||||
}
|
||||
|
||||
export function escapeShell(s: string): string {
|
||||
return s.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
export function isRootOnlyPath(path: string): boolean {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
return (
|
||||
normalized === "/" ||
|
||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||
/^[A-Za-z]:$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPermissionError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("eacces") ||
|
||||
msg.includes("access denied")
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface TransferIntegrityResult {
|
||||
algorithm: "sha256";
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export async function hashSftpFile(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<string> {
|
||||
const stream = sftp.createReadStream(path);
|
||||
const hash = createHash("sha256");
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (shouldAbort()) {
|
||||
const error = createAbortError();
|
||||
stream.destroy(error);
|
||||
throw error;
|
||||
}
|
||||
hash.update(chunk);
|
||||
}
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (shouldAbort()) throw createAbortError();
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export async function verifySftpFileIntegrity(
|
||||
sourceSftp: SFTPWrapper,
|
||||
destSftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destPath: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<TransferIntegrityResult> {
|
||||
const [sourceDigest, destDigest] = await Promise.all([
|
||||
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
|
||||
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
|
||||
]);
|
||||
|
||||
if (sourceDigest !== destDigest) {
|
||||
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
|
||||
}
|
||||
|
||||
return { algorithm: "sha256", digest: sourceDigest };
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { TransferPlatform } from "../transfer-paths.js";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
||||
|
||||
export interface TransferScanSummary {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
largestFileBytes: number;
|
||||
/** Share of total bytes in likely incompressible file types (0–1). */
|
||||
incompressibleRatio: number;
|
||||
/** Share inferred from bounded content samples, when available. */
|
||||
sampledIncompressibleRatio?: number;
|
||||
}
|
||||
|
||||
const INCOMPRESSIBLE_EXT =
|
||||
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export function isLikelyIncompressiblePath(path: string): boolean {
|
||||
return INCOMPRESSIBLE_EXT.test(path);
|
||||
}
|
||||
|
||||
export function buildTransferScanSummary(
|
||||
items: Array<{ sourcePath: string; size: number }>,
|
||||
): TransferScanSummary {
|
||||
let totalBytes = 0;
|
||||
let largestFileBytes = 0;
|
||||
let incompressibleBytes = 0;
|
||||
|
||||
for (const item of items) {
|
||||
totalBytes += item.size;
|
||||
largestFileBytes = Math.max(largestFileBytes, item.size);
|
||||
if (isLikelyIncompressiblePath(item.sourcePath)) {
|
||||
incompressibleBytes += item.size;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
largestFileBytes,
|
||||
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateIncompressibleSample(data: Buffer): boolean {
|
||||
if (data.length === 0) return false;
|
||||
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
|
||||
}
|
||||
|
||||
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
|
||||
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose tar vs per-item SFTP for directory / multi-file transfers.
|
||||
* Single-file stream transfers bypass this entirely.
|
||||
*/
|
||||
export function resolveArchiveTransferMethod(
|
||||
preference: TransferMethodPreference,
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): "tar" | "item_sftp" {
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "item_sftp") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
|
||||
}
|
||||
|
||||
// auto
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (fileCount === 0) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
// Multi-item sets with a large file: tar only when data is likely compressible
|
||||
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount >= 100) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount > 5 && incompressibleRatio < 0.7) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
export type ArchiveTransferReasonKey =
|
||||
| "user_item_sftp"
|
||||
| "user_tar"
|
||||
| "tar_unavailable"
|
||||
| "windows_host"
|
||||
| "auto_multi_large"
|
||||
| "auto_single_large_in_archive"
|
||||
| "auto_many_incompressible"
|
||||
| "auto_many_files"
|
||||
| "auto_default";
|
||||
|
||||
/** i18n key suffix under transfer.methodReason.* */
|
||||
export function getArchiveTransferReasonKey(
|
||||
preference: TransferMethodPreference,
|
||||
resolvedMethod: "tar" | "item_sftp",
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): ArchiveTransferReasonKey {
|
||||
if (preference === "item_sftp") return "user_item_sftp";
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
|
||||
}
|
||||
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "windows_host";
|
||||
}
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "tar_unavailable";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "auto_multi_large";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "auto_single_large_in_archive";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
|
||||
if (
|
||||
fileCount >= 100 ||
|
||||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
|
||||
(fileCount > 5 && incompressibleRatio < 0.7)
|
||||
) {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
if (resolvedMethod === "tar") {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
return "auto_default";
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { basename, joinPath } from "../transfer-paths.js";
|
||||
import {
|
||||
SFTP_OPEN_READ,
|
||||
promisifySftpClose,
|
||||
promisifySftpOpen,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpStat,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface FileWorkItem {
|
||||
sourcePath: string;
|
||||
destPath: string;
|
||||
mode: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function collectFileWorkItems(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destRoot: string,
|
||||
destBaseName?: string,
|
||||
): Promise<FileWorkItem[]> {
|
||||
const stats = await promisifySftpStat(sftp, sourcePath);
|
||||
const name = destBaseName ?? basename(sourcePath);
|
||||
const destPath = joinPath(destRoot, name);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return [
|
||||
{
|
||||
sourcePath,
|
||||
destPath,
|
||||
mode: stats.mode & 0o7777,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: FileWorkItem[] = [];
|
||||
const walk = async (srcDir: string, dstDir: string) => {
|
||||
const entries = await promisifySftpReaddir(sftp, srcDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
const srcChild = joinPath(srcDir, entry.filename);
|
||||
const dstChild = joinPath(dstDir, entry.filename);
|
||||
|
||||
if (entry.attrs.isDirectory()) {
|
||||
await walk(srcChild, dstChild);
|
||||
} else if (entry.attrs.isFile()) {
|
||||
items.push({
|
||||
sourcePath: srcChild,
|
||||
destPath: dstChild,
|
||||
mode: entry.attrs.mode & 0o7777,
|
||||
size: entry.attrs.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(sourcePath, destPath);
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function readSftpSample(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
fileSize: number,
|
||||
): Promise<Buffer> {
|
||||
const sampleSize = Math.min(64 * 1024, fileSize);
|
||||
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||
try {
|
||||
const buffer = Buffer.alloc(sampleSize);
|
||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
||||
if (err) reject(err);
|
||||
else resolve(count);
|
||||
});
|
||||
});
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await promisifySftpClose(sftp, handle).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||
|
||||
export interface SegmentCopyJob {
|
||||
offset: number;
|
||||
length: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
export function clampParallelSegmentCount(value?: number): number {
|
||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function buildSegmentCopyJobs(
|
||||
fileSize: number,
|
||||
initialOffset: number,
|
||||
destResumeSize: number,
|
||||
): SegmentCopyJob[] {
|
||||
const jobs: SegmentCopyJob[] = [];
|
||||
for (
|
||||
let offset = initialOffset;
|
||||
offset < fileSize;
|
||||
offset += SFTP_XFER_SEGMENT_SIZE
|
||||
) {
|
||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||
if (destResumeSize >= offset + length) {
|
||||
continue;
|
||||
}
|
||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||
jobs.push({
|
||||
offset: start,
|
||||
length: offset + length - start,
|
||||
segmentIndex,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
buildPathFromSegments,
|
||||
joinPath,
|
||||
normalizeSftpPath,
|
||||
splitPathSegments,
|
||||
} from "../transfer-paths.js";
|
||||
import { isRootOnlyPath } from "./transfer-host-utils.js";
|
||||
import {
|
||||
promisifySftpMkdir,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpRmdir,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export async function ensureDirectoryTreeSftp(
|
||||
sftp: SFTPWrapper,
|
||||
dirPath: string,
|
||||
created: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
const normalized = normalizeSftpPath(dirPath);
|
||||
if (!normalized || isRootOnlyPath(normalized)) return;
|
||||
|
||||
const { root, segments } = splitPathSegments(normalized);
|
||||
if (segments.length === 0) return;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const current = buildPathFromSegments(root, segments, i + 1);
|
||||
if (created.has(current)) continue;
|
||||
|
||||
try {
|
||||
await promisifySftpMkdir(sftp, current, 0o755);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
try {
|
||||
const stats = await promisifySftpStat(sftp, current);
|
||||
if (!stats.isDirectory()) throw err;
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
created.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePathSftp(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
let stats: import("ssh2").Stats;
|
||||
try {
|
||||
stats = await promisifySftpStat(sftp, path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await promisifySftpReaddir(sftp, path);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
||||
}
|
||||
await promisifySftpRmdir(sftp, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await promisifySftpUnlink(sftp, path);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||
|
||||
export type TransferHopId =
|
||||
"source_read" | "dest_sftp_write" | "dest_local_write";
|
||||
|
||||
export interface TransferHopMetrics {
|
||||
id: TransferHopId;
|
||||
bytes: number;
|
||||
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
||||
spanMs: number;
|
||||
mbPerSec: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
prepareDestMs?: number;
|
||||
compressMs?: number;
|
||||
transferMs?: number;
|
||||
extractMs?: number;
|
||||
verifyMs?: number;
|
||||
directBenchmarkMs?: number;
|
||||
relayBenchmarkMs?: number;
|
||||
sourceDeleteMs?: number;
|
||||
totalMs?: number;
|
||||
transferBytes?: number;
|
||||
endToEndMbPerSec?: number;
|
||||
hops?: TransferHopMetrics[];
|
||||
}
|
||||
|
||||
export function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
export function computeTransferMbPerSec(
|
||||
bytes: number,
|
||||
ms: number,
|
||||
): number | undefined {
|
||||
if (ms <= 0 || bytes <= 0) return undefined;
|
||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||
}
|
||||
|
||||
export interface HopWallClock {
|
||||
firstAt: number | null;
|
||||
lastAt: number | null;
|
||||
}
|
||||
|
||||
export function createHopWallClock(): HopWallClock {
|
||||
return { firstAt: null, lastAt: null };
|
||||
}
|
||||
|
||||
export function noteHopStart(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
if (clock.firstAt === null) clock.firstAt = t;
|
||||
}
|
||||
|
||||
export function noteHopEnd(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
clock.lastAt = t;
|
||||
}
|
||||
|
||||
export function hopSpanMs(clock: HopWallClock): number {
|
||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||
}
|
||||
|
||||
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||
let pending = 0;
|
||||
let lastFlush = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (pending > 0) {
|
||||
onProgress?.(pending);
|
||||
pending = 0;
|
||||
lastFlush = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
add(bytes: number) {
|
||||
pending += bytes;
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelinedXferStats {
|
||||
bytes: number;
|
||||
sourceReadSpanMs: number;
|
||||
destWriteSpanMs: number;
|
||||
destWriteKind: "sftp" | "local";
|
||||
}
|
||||
|
||||
export function createEmptyXferStats(): PipelinedXferStats {
|
||||
return {
|
||||
bytes: 0,
|
||||
sourceReadSpanMs: 0,
|
||||
destWriteSpanMs: 0,
|
||||
destWriteKind: "sftp",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeXferStats(
|
||||
target: PipelinedXferStats,
|
||||
source: PipelinedXferStats,
|
||||
): void {
|
||||
target.bytes += source.bytes;
|
||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||
target.destWriteKind = source.destWriteKind;
|
||||
}
|
||||
|
||||
export function buildTransferHopTimings(
|
||||
stats: PipelinedXferStats,
|
||||
transferMs: number,
|
||||
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
||||
const hops: TransferHopMetrics[] = [];
|
||||
|
||||
const sourceRate = computeTransferMbPerSec(
|
||||
stats.bytes,
|
||||
stats.sourceReadSpanMs,
|
||||
);
|
||||
if (sourceRate !== undefined) {
|
||||
hops.push({
|
||||
id: "source_read",
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.sourceReadSpanMs,
|
||||
mbPerSec: sourceRate,
|
||||
});
|
||||
}
|
||||
|
||||
const destHopId: TransferHopId =
|
||||
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
||||
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
||||
if (destRate !== undefined) {
|
||||
hops.push({
|
||||
id: destHopId,
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.destWriteSpanMs,
|
||||
mbPerSec: destRate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
transferBytes: stats.bytes,
|
||||
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
||||
hops,
|
||||
};
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface TransferPerformanceProfile {
|
||||
throughputBps: number;
|
||||
failureRate: number;
|
||||
samples: number;
|
||||
preferredLanes: number;
|
||||
pipelineConcurrency: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TransferTuning {
|
||||
parallelSegmentCount: number;
|
||||
pipelineConcurrency: number;
|
||||
}
|
||||
|
||||
export interface DirectRouteProfile {
|
||||
directMs: number;
|
||||
relayMs: number;
|
||||
failureRate: number;
|
||||
benchmarkSamples: number;
|
||||
outcomeSamples: number;
|
||||
benchmarkedAt: number;
|
||||
cooldownUntil?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PROFILES = 128;
|
||||
const STORE_VERSION = 1;
|
||||
const STORE_FILENAME = "adaptive-transfer-profiles.json";
|
||||
const profiles = new Map<string, TransferPerformanceProfile>();
|
||||
const directRoutes = new Map<string, DirectRouteProfile>();
|
||||
let loadedPath: string | undefined;
|
||||
let loadPromise: Promise<void> | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistPromise = Promise.resolve();
|
||||
|
||||
interface PersistedProfiles {
|
||||
version: typeof STORE_VERSION;
|
||||
profiles: Record<string, TransferPerformanceProfile>;
|
||||
directRoutes: Record<string, DirectRouteProfile>;
|
||||
}
|
||||
|
||||
function validDirectRoute(value: unknown): value is DirectRouteProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<DirectRouteProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.directMs) &&
|
||||
Number(profile.directMs) > 0 &&
|
||||
Number.isFinite(profile.relayMs) &&
|
||||
Number(profile.relayMs) > 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.benchmarkSamples) &&
|
||||
Number(profile.benchmarkSamples) > 0 &&
|
||||
Number.isFinite(profile.outcomeSamples) &&
|
||||
Number(profile.outcomeSamples) >= 0 &&
|
||||
Number.isFinite(profile.benchmarkedAt) &&
|
||||
(profile.cooldownUntil === undefined ||
|
||||
Number.isFinite(profile.cooldownUntil)) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, STORE_FILENAME);
|
||||
}
|
||||
|
||||
function profileId(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function validProfile(value: unknown): value is TransferPerformanceProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<TransferPerformanceProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.throughputBps) &&
|
||||
Number(profile.throughputBps) >= 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.samples) &&
|
||||
Number(profile.samples) > 0 &&
|
||||
Number.isFinite(profile.preferredLanes) &&
|
||||
Number.isFinite(profile.pipelineConcurrency) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function trimProfiles(now = Date.now()): void {
|
||||
for (const [key, profile] of profiles) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
|
||||
}
|
||||
const recent = [...profiles.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
profiles.clear();
|
||||
for (const [key, profile] of recent) profiles.set(key, profile);
|
||||
|
||||
for (const [key, profile] of directRoutes) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
|
||||
}
|
||||
const recentRoutes = [...directRoutes.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
directRoutes.clear();
|
||||
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
|
||||
}
|
||||
|
||||
async function persistProfiles(): Promise<void> {
|
||||
trimProfiles();
|
||||
const target = storePath();
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
const payload: PersistedProfiles = {
|
||||
version: STORE_VERSION,
|
||||
profiles: Object.fromEntries(profiles),
|
||||
directRoutes: Object.fromEntries(directRoutes),
|
||||
};
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(temporary, JSON.stringify(payload), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(temporary, target);
|
||||
} catch {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function queuePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = undefined;
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
}, 250);
|
||||
persistTimer.unref?.();
|
||||
}
|
||||
|
||||
export async function initializeTransferProfiles(): Promise<void> {
|
||||
const target = storePath();
|
||||
if (loadedPath === target) return loadPromise;
|
||||
loadedPath = target;
|
||||
loadPromise = (async () => {
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(target, "utf8"),
|
||||
) as Partial<PersistedProfiles>;
|
||||
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
||||
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
||||
profiles.set(key, profile);
|
||||
}
|
||||
}
|
||||
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
|
||||
directRoutes.set(key, profile);
|
||||
}
|
||||
}
|
||||
trimProfiles();
|
||||
} catch {
|
||||
// Missing or malformed local learning data must not affect transfers.
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export async function flushTransferProfiles(): Promise<void> {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
}
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
await persistPromise;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function selectTransferTuning(
|
||||
fileSize: number,
|
||||
profile?: TransferPerformanceProfile,
|
||||
requestedLanes?: number,
|
||||
): TransferTuning {
|
||||
if (fileSize < 32 * MB) {
|
||||
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
|
||||
}
|
||||
|
||||
let lanes = fileSize >= 1024 * MB ? 4 : 2;
|
||||
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
|
||||
|
||||
if (profile) {
|
||||
lanes = profile.preferredLanes;
|
||||
pipelineConcurrency = profile.pipelineConcurrency;
|
||||
if (profile.failureRate >= 0.2) {
|
||||
lanes = Math.min(lanes, 2);
|
||||
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedLanes !== undefined) lanes = requestedLanes;
|
||||
|
||||
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
|
||||
return {
|
||||
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
|
||||
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTransferProfile(
|
||||
previous: TransferPerformanceProfile | undefined,
|
||||
observation: {
|
||||
bytes: number;
|
||||
durationMs: number;
|
||||
lanes: number;
|
||||
pipelineConcurrency: number;
|
||||
failed: boolean;
|
||||
now?: number;
|
||||
},
|
||||
): TransferPerformanceProfile {
|
||||
const now = observation.now ?? Date.now();
|
||||
const throughputBps =
|
||||
observation.durationMs > 0
|
||||
? (observation.bytes * 1000) / observation.durationMs
|
||||
: 0;
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const smoothedThroughput = previous
|
||||
? previous.throughputBps * (1 - weight) + throughputBps * weight
|
||||
: throughputBps;
|
||||
const failure = observation.failed ? 1 : 0;
|
||||
const failureRate = previous
|
||||
? previous.failureRate * (1 - weight) + failure * weight
|
||||
: failure;
|
||||
|
||||
let preferredLanes = observation.lanes;
|
||||
let pipelineConcurrency = observation.pipelineConcurrency;
|
||||
if (failureRate >= 0.2) {
|
||||
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
|
||||
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
|
||||
} else if (
|
||||
!observation.failed &&
|
||||
previous &&
|
||||
previous.samples >= 2 &&
|
||||
throughputBps > previous.throughputBps * 1.25
|
||||
) {
|
||||
preferredLanes = Math.min(8, preferredLanes + 1);
|
||||
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
|
||||
}
|
||||
|
||||
return {
|
||||
throughputBps: smoothedThroughput,
|
||||
failureRate,
|
||||
samples: (previous?.samples ?? 0) + 1,
|
||||
preferredLanes,
|
||||
pipelineConcurrency,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTransferProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): TransferPerformanceProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = profiles.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
profiles.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordTransferProfile(
|
||||
key: string,
|
||||
observation: Parameters<typeof updateTransferProfile>[1],
|
||||
): TransferPerformanceProfile {
|
||||
const profile = updateTransferProfile(getTransferProfile(key), observation);
|
||||
profiles.set(profileId(key), profile);
|
||||
trimProfiles(observation.now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getDirectRouteProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = directRoutes.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
directRoutes.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordDirectRouteBenchmark(
|
||||
key: string,
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const profile: DirectRouteProfile = {
|
||||
directMs: previous
|
||||
? previous.directMs * (1 - weight) + directMs * weight
|
||||
: directMs,
|
||||
relayMs: previous
|
||||
? previous.relayMs * (1 - weight) + relayMs * weight
|
||||
: relayMs,
|
||||
failureRate: previous?.failureRate ?? 0,
|
||||
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
|
||||
outcomeSamples: previous?.outcomeSamples ?? 0,
|
||||
benchmarkedAt: now,
|
||||
cooldownUntil: previous?.cooldownUntil,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
trimProfiles(now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function recordDirectRouteOutcome(
|
||||
key: string,
|
||||
failed: boolean,
|
||||
now = Date.now(),
|
||||
cooldownMs = 10 * 60 * 1000,
|
||||
): DirectRouteProfile | undefined {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
if (!previous) return undefined;
|
||||
const failure = failed ? 1 : 0;
|
||||
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
|
||||
const profile = {
|
||||
...previous,
|
||||
failureRate: previous.failureRate * (1 - weight) + failure * weight,
|
||||
outcomeSamples: previous.outcomeSamples + 1,
|
||||
cooldownUntil: failed ? now + cooldownMs : undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getRecentDirectRouteDecision(
|
||||
key: string,
|
||||
maxAgeMs: number,
|
||||
now = Date.now(),
|
||||
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
|
||||
const profile = getDirectRouteProfile(key, now);
|
||||
if (!profile) return undefined;
|
||||
if ((profile.cooldownUntil ?? 0) > now) {
|
||||
return {
|
||||
useDirect: false,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
|
||||
return {
|
||||
useDirect:
|
||||
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearTransferProfiles(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
loadedPath = undefined;
|
||||
loadPromise = undefined;
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper, Stats } from "ssh2";
|
||||
|
||||
export interface TrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
originalPath: string;
|
||||
isDirectory: boolean;
|
||||
deletedAt: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type StoredTrashItem = TrashItem & { trashPath: string };
|
||||
|
||||
const TRASH_DIR = ".termix-trash";
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
|
||||
function call<T>(
|
||||
run: (done: (error: Error | undefined, value: T) => void) => void,
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
run((error, value) => (error ? reject(error) : resolve(value)));
|
||||
});
|
||||
}
|
||||
|
||||
function stat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.stat(target, done));
|
||||
}
|
||||
|
||||
function lstat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.lstat(target, done));
|
||||
}
|
||||
|
||||
function readdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
|
||||
}
|
||||
|
||||
function readFile(sftp: SFTPWrapper, target: string) {
|
||||
return call<Buffer>((done) => sftp.readFile(target, done));
|
||||
}
|
||||
|
||||
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
|
||||
return call<void>((done) => sftp.writeFile(target, data, done));
|
||||
}
|
||||
|
||||
function rename(sftp: SFTPWrapper, from: string, to: string) {
|
||||
return call<void>((done) => sftp.rename(from, to, done));
|
||||
}
|
||||
|
||||
function unlink(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.unlink(target, done));
|
||||
}
|
||||
|
||||
function mkdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.mkdir(target, done));
|
||||
}
|
||||
|
||||
function rmdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.rmdir(target, done));
|
||||
}
|
||||
|
||||
async function exists(sftp: SFTPWrapper, target: string) {
|
||||
try {
|
||||
await stat(sftp, target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
|
||||
const normalized = target.replace(/\\/g, "/");
|
||||
const root = normalized.startsWith("/") ? "/" : "";
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
for (const part of parts) {
|
||||
current =
|
||||
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
|
||||
if (await exists(sftp, current)) continue;
|
||||
await mkdir(sftp, current);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
|
||||
const targetStat = await lstat(sftp, target);
|
||||
if (!targetStat.isDirectory()) {
|
||||
await unlink(sftp, target);
|
||||
return;
|
||||
}
|
||||
for (const entry of await readdir(sftp, target)) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await removeTree(sftp, path.posix.join(target, entry.filename));
|
||||
}
|
||||
await rmdir(sftp, target);
|
||||
}
|
||||
|
||||
async function trashPaths(sftp: SFTPWrapper) {
|
||||
const home = await call<string>((done) => sftp.realpath(".", done));
|
||||
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
|
||||
const files = path.posix.join(root, "files");
|
||||
const info = path.posix.join(root, "info");
|
||||
await ensureDirectory(sftp, files);
|
||||
await ensureDirectory(sftp, info);
|
||||
return { root, files, info };
|
||||
}
|
||||
|
||||
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
|
||||
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
|
||||
const root = path.posix.normalize(trashRoot);
|
||||
return (
|
||||
normalized !== "." &&
|
||||
normalized !== "/" &&
|
||||
!/^[A-Za-z]:\/?$/.test(normalized) &&
|
||||
normalized !== root &&
|
||||
!normalized.startsWith(`${root}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function publicItem(item: StoredTrashItem): TrashItem {
|
||||
const { trashPath: _trashPath, ...result } = item;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readStoredItem(
|
||||
sftp: SFTPWrapper,
|
||||
dirs: { root: string; files: string; info: string },
|
||||
id: string,
|
||||
): Promise<StoredTrashItem> {
|
||||
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
|
||||
const parsed = JSON.parse(
|
||||
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
|
||||
"utf8",
|
||||
),
|
||||
) as StoredTrashItem;
|
||||
if (
|
||||
parsed.id !== id ||
|
||||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
|
||||
!isSafeTrashSource(parsed.originalPath, dirs.root)
|
||||
) {
|
||||
throw new Error("Invalid trash metadata");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function moveToTrash(
|
||||
sftp: SFTPWrapper,
|
||||
itemPath: string,
|
||||
): Promise<TrashItem> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
if (!isSafeTrashSource(itemPath, dirs.root)) {
|
||||
throw new Error("This path cannot be moved to trash");
|
||||
}
|
||||
const itemStat = await lstat(sftp, itemPath);
|
||||
const id = crypto.randomUUID();
|
||||
const trashPath = path.posix.join(dirs.files, id);
|
||||
const item: StoredTrashItem = {
|
||||
id,
|
||||
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
|
||||
originalPath: itemPath,
|
||||
trashPath,
|
||||
isDirectory: itemStat.isDirectory(),
|
||||
deletedAt: new Date().toISOString(),
|
||||
size: itemStat.size,
|
||||
};
|
||||
|
||||
await rename(sftp, itemPath, trashPath);
|
||||
try {
|
||||
await writeFile(
|
||||
sftp,
|
||||
path.posix.join(dirs.info, `${id}.json`),
|
||||
JSON.stringify(item),
|
||||
);
|
||||
} catch (error) {
|
||||
await rename(sftp, trashPath, itemPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function listTrash(
|
||||
sftp: SFTPWrapper,
|
||||
retentionDays: number,
|
||||
): Promise<TrashItem[]> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
const items: TrashItem[] = [];
|
||||
for (const entry of await readdir(sftp, dirs.info)) {
|
||||
if (!entry.filename.endsWith(".json")) continue;
|
||||
const id = entry.filename.slice(0, -5);
|
||||
try {
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (new Date(item.deletedAt).getTime() < cutoff) {
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
continue;
|
||||
}
|
||||
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
|
||||
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
} catch {
|
||||
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
}
|
||||
|
||||
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.originalPath)) {
|
||||
throw new Error("A file already exists at the original path");
|
||||
}
|
||||
await rename(sftp, item.trashPath, item.originalPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function permanentlyDeleteTrashItem(
|
||||
sftp: SFTPWrapper,
|
||||
id: string,
|
||||
) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
}
|
||||
|
||||
export async function emptyTrash(sftp: SFTPWrapper) {
|
||||
const items = await listTrash(sftp, 365_000);
|
||||
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
|
||||
return items.length;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
export function isExecutableFile(
|
||||
permissions: string,
|
||||
fileName: string,
|
||||
): boolean {
|
||||
const hasExecutePermission =
|
||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||
|
||||
const scriptExtensions = [
|
||||
".sh",
|
||||
".py",
|
||||
".pl",
|
||||
".rb",
|
||||
".js",
|
||||
".php",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
];
|
||||
const hasScriptExtension = scriptExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const executableExtensions = [".bin", ".exe", ".out"];
|
||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||
|
||||
return (
|
||||
hasExecutePermission &&
|
||||
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
|
||||
);
|
||||
}
|
||||
|
||||
export function modeToPermissions(mode: number): string {
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFLNK = 0o120000;
|
||||
const S_IFMT = 0o170000;
|
||||
|
||||
const type = mode & S_IFMT;
|
||||
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
|
||||
|
||||
const perms = [
|
||||
mode & 0o400 ? "r" : "-",
|
||||
mode & 0o200 ? "w" : "-",
|
||||
mode & 0o100 ? "x" : "-",
|
||||
mode & 0o040 ? "r" : "-",
|
||||
mode & 0o020 ? "w" : "-",
|
||||
mode & 0o010 ? "x" : "-",
|
||||
mode & 0o004 ? "r" : "-",
|
||||
mode & 0o002 ? "w" : "-",
|
||||
mode & 0o001 ? "x" : "-",
|
||||
].join("");
|
||||
|
||||
return prefix + perms;
|
||||
}
|
||||
|
||||
export function parseLsDateToTimestamp(dateStr: string): number {
|
||||
const parts = dateStr.trim().split(/\s+/);
|
||||
if (parts.length < 3) return 0;
|
||||
|
||||
const [month, day, timeOrYear] = parts;
|
||||
const monthIndex = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
].indexOf(month);
|
||||
if (monthIndex === -1) return 0;
|
||||
|
||||
const dayNum = parseInt(day, 10);
|
||||
const now = new Date();
|
||||
|
||||
if (timeOrYear.includes(":")) {
|
||||
const [hours, minutes] = timeOrYear.split(":").map(Number);
|
||||
let year = now.getFullYear();
|
||||
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
|
||||
if (candidate > now) year -= 1;
|
||||
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
|
||||
}
|
||||
|
||||
const year = parseInt(timeOrYear, 10);
|
||||
if (isNaN(year)) return 0;
|
||||
return new Date(year, monthIndex, dayNum).getTime() / 1000;
|
||||
}
|
||||
|
||||
export function formatMtime(mtime: number): string {
|
||||
const date = new Date(mtime * 1000);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate().toString().padStart(2, " ");
|
||||
const now = new Date();
|
||||
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (date > sixMonthsAgo) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${month} ${day} ${hours}:${minutes}`;
|
||||
}
|
||||
return `${month} ${day} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
js: "text/javascript",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
export function detectBinary(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
|
||||
const sampleSize = Math.min(buffer.length, 8192);
|
||||
let nullBytes = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
if (++nullBytes > 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return nullBytes / sampleSize > 0.01;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export const GUACD_DRIVE_PATH_ENV = "GUACD_DRIVE_PATH";
|
||||
const DEFAULT_DRIVE_ROOT = "/drive";
|
||||
|
||||
/**
|
||||
* Fills in where guacd keeps the files behind RDP drive redirection.
|
||||
*
|
||||
* The folder lives on the guacd host, not on Termix's - with the stock
|
||||
* compose it is a directory in the shared termix-data volume, configured
|
||||
* through GUACD_DRIVE_PATH. Each user gets a folder of their own underneath:
|
||||
* a shared drive would show everyone's uploads to everyone else. A host that
|
||||
* names its own drive-path keeps it.
|
||||
*/
|
||||
export function withDriveSettings(
|
||||
guacConfig: Record<string, unknown>,
|
||||
userId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, unknown> {
|
||||
if (!guacConfig["enable-drive"] || guacConfig["drive-path"]) {
|
||||
return guacConfig;
|
||||
}
|
||||
const root = (
|
||||
env[GUACD_DRIVE_PATH_ENV]?.trim() || DEFAULT_DRIVE_ROOT
|
||||
).replace(/\/+$/, "");
|
||||
return {
|
||||
...guacConfig,
|
||||
"drive-path": `${root}/${userId}`,
|
||||
"create-drive-path": true,
|
||||
};
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
type GuacamoleRecordingMetadata,
|
||||
} from "./token-service.js";
|
||||
import {
|
||||
createCurrentSessionRecordingRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const GUACAMOLE_RECORDINGS_DIR =
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export interface GuacSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||
// primary connection's guacd handshake completes.
|
||||
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||
export function getGuacSessionByConnectId(
|
||||
connectId: string,
|
||||
userId: string,
|
||||
): GuacSessionInfo | null {
|
||||
const info = guacSessionByConnectId.get(connectId);
|
||||
return info?.ownerUserId === userId ? info : null;
|
||||
}
|
||||
|
||||
export function getGuacSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacSessionInfo | null {
|
||||
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
async function persistGuacamoleRecording(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
const recording = clientConnection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
|
||||
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
|
||||
if (!resolvedPath.startsWith(allowedBase)) return;
|
||||
|
||||
// guacd may flush/rename the recording just after the websocket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
|
||||
guacLogger.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume — set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await createCurrentSessionRecordingRepository().create({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
});
|
||||
}
|
||||
|
||||
const websocketOptions = {
|
||||
host: "127.0.0.1",
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
// macOS Screen Sharing negotiates its VNC security type over several
|
||||
// round trips (RFB type 30 -> 33/36/2/35) and can fail the first
|
||||
// attempt; retrying lets guacd's VNC client re-establish instead of
|
||||
// guacd giving up immediately (Support#1063).
|
||||
autoretry: 2,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
const info: GuacSessionInfo = {
|
||||
guacamoleConnectionId,
|
||||
hostId: termixMeta.hostId,
|
||||
ownerUserId: termixMeta.ownerUserId,
|
||||
protocol: termixMeta.protocol,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||
}
|
||||
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
let guacServer = createGuacServer();
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -1,15 +0,0 @@
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
export function resolveJumpTunnelEndpoint(
|
||||
guacdHost: string,
|
||||
tunnelHost = process.env.GUACD_TUNNEL_HOST,
|
||||
): { bindHost: string; advertisedHost: string } {
|
||||
if (LOOPBACK_HOSTS.has(guacdHost.toLowerCase())) {
|
||||
return { bindHost: "127.0.0.1", advertisedHost: "127.0.0.1" };
|
||||
}
|
||||
|
||||
return {
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: tunnelHost?.trim() || "termix",
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import net from "net";
|
||||
|
||||
const RFB_BANNER_LENGTH = 12;
|
||||
const VNC_AUTH_SECURITY_TYPE = 2;
|
||||
const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii");
|
||||
const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii");
|
||||
|
||||
export interface VncCompatibilityProxy {
|
||||
port: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export async function createMacosVncCompatibilityProxy({
|
||||
targetHost,
|
||||
targetPort,
|
||||
bindHost,
|
||||
}: {
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost: string;
|
||||
}): Promise<VncCompatibilityProxy> {
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((client) => {
|
||||
const upstream = net.createConnection(targetPort, targetHost);
|
||||
sockets.add(client);
|
||||
sockets.add(upstream);
|
||||
|
||||
const forget = (socket: net.Socket) => sockets.delete(socket);
|
||||
client.once("close", () => forget(client));
|
||||
upstream.once("close", () => forget(upstream));
|
||||
client.once("error", () => upstream.destroy());
|
||||
upstream.once("error", () => client.destroy());
|
||||
client.pipe(upstream);
|
||||
|
||||
let pending = Buffer.alloc(0);
|
||||
const forwardMacosSecurityTypes = (chunk: Buffer) => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
if (pending.length < 1) return;
|
||||
|
||||
const securityTypeCount = pending[0];
|
||||
if (securityTypeCount === 0) {
|
||||
upstream.off("data", forwardMacosSecurityTypes);
|
||||
client.write(pending);
|
||||
upstream.pipe(client);
|
||||
return;
|
||||
}
|
||||
if (pending.length < 1 + securityTypeCount) return;
|
||||
|
||||
upstream.off("data", forwardMacosSecurityTypes);
|
||||
const securityTypes = pending.subarray(1, 1 + securityTypeCount);
|
||||
if (securityTypes.includes(VNC_AUTH_SECURITY_TYPE)) {
|
||||
client.write(Buffer.from([1, VNC_AUTH_SECURITY_TYPE]));
|
||||
} else {
|
||||
client.write(pending.subarray(0, 1 + securityTypeCount));
|
||||
}
|
||||
client.write(pending.subarray(1 + securityTypeCount));
|
||||
upstream.pipe(client);
|
||||
};
|
||||
|
||||
const forwardServerBanner = (chunk: Buffer) => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
if (pending.length < RFB_BANNER_LENGTH) return;
|
||||
|
||||
upstream.off("data", forwardServerBanner);
|
||||
const banner = pending.subarray(0, RFB_BANNER_LENGTH);
|
||||
const remainder = pending.subarray(RFB_BANNER_LENGTH);
|
||||
if (banner.equals(MACOS_RFB_BANNER)) {
|
||||
client.write(STANDARD_RFB_BANNER);
|
||||
pending = Buffer.alloc(0);
|
||||
upstream.on("data", forwardMacosSecurityTypes);
|
||||
if (remainder.length > 0) forwardMacosSecurityTypes(remainder);
|
||||
} else {
|
||||
client.write(banner);
|
||||
client.write(remainder);
|
||||
upstream.pipe(client);
|
||||
}
|
||||
};
|
||||
upstream.on("data", forwardServerBanner);
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, bindHost, () => {
|
||||
server.off("error", reject);
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
server.unref();
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => {
|
||||
server.close();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
interface RdpSettingsInput {
|
||||
port: number;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert: boolean;
|
||||
guacConfig: Record<string, unknown>;
|
||||
guacdOverrides: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function buildRdpSettings({
|
||||
port,
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacConfig,
|
||||
guacdOverrides,
|
||||
}: RdpSettingsInput): Record<string, unknown> {
|
||||
return {
|
||||
...guacConfig,
|
||||
port,
|
||||
domain,
|
||||
...(security === undefined ? {} : { security }),
|
||||
"ignore-cert": ignoreCert,
|
||||
...guacdOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRdpDomain(
|
||||
authType: string | null,
|
||||
promptedDomain: unknown,
|
||||
storedDomain: string,
|
||||
): string {
|
||||
return authType === "none" && typeof promptedDomain === "string"
|
||||
? promptedDomain
|
||||
: storedDomain;
|
||||
}
|
||||
|
||||
type SharedRdpAuthResolution =
|
||||
| { source: "personal-override" }
|
||||
| { source: "owner-shared"; authType: string }
|
||||
| { source: "secretless" }
|
||||
| { source: "required" }
|
||||
| null;
|
||||
|
||||
export function resolveRdpAuthTypeForConnect({
|
||||
storedAuthType,
|
||||
credentialId,
|
||||
sharedResolution,
|
||||
}: {
|
||||
storedAuthType?: string | null;
|
||||
credentialId?: number | null;
|
||||
sharedResolution?: SharedRdpAuthResolution;
|
||||
}): string {
|
||||
if (sharedResolution === undefined) {
|
||||
return storedAuthType || (credentialId ? "credential" : "direct");
|
||||
}
|
||||
if (sharedResolution?.source === "personal-override") return "credential";
|
||||
if (sharedResolution?.source === "owner-shared") {
|
||||
return sharedResolution.authType;
|
||||
}
|
||||
return sharedResolution?.source === "secretless" && storedAuthType === "none"
|
||||
? "none"
|
||||
: "direct";
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Merges Termix's recording bookkeeping into a host's guacd settings.
|
||||
*
|
||||
* Location and filename are not the host's to choose: recordings are indexed by
|
||||
* them for playback, and the backend refuses to read anything outside its
|
||||
* recordings directory. What a recording *contains* is a host-level decision, so
|
||||
* those flags are only defaulted, never overwritten.
|
||||
*/
|
||||
export function withRecordingSettings(
|
||||
guacConfig: Record<string, unknown>,
|
||||
recordingPath: string,
|
||||
recordingName: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...guacConfig,
|
||||
"recording-path": recordingPath,
|
||||
"recording-name": recordingName,
|
||||
"create-recording-path": true,
|
||||
"recording-exclude-output": guacConfig["recording-exclude-output"] ?? false,
|
||||
"recording-include-keys": guacConfig["recording-include-keys"] ?? true,
|
||||
};
|
||||
}
|
||||
@@ -1,831 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { withRecordingSettings } from "./recording-settings.js";
|
||||
import { withDriveSettings } from "./drive-settings.js";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
type RecipientSharedHostAuthResolution,
|
||||
} from "../../utils/shared-host-auth-resolver.js";
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "./rdp-settings.js";
|
||||
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
|
||||
if (!req.userId)
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
const session = getGuacSessionByConnectId(
|
||||
String(req.params.connectId),
|
||||
req.userId,
|
||||
);
|
||||
res.json({ guacamoleConnectionId: session?.guacamoleConnectionId ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /guacamole/token:
|
||||
* post:
|
||||
* summary: Generate an encrypted Guacamole connection token
|
||||
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
|
||||
* tags:
|
||||
* - Guacamole
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* - hostname
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* hostname:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Encrypted connection token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const { type, hostname, port, username, password, domain, ...rawOptions } =
|
||||
req.body;
|
||||
|
||||
// Strip "auto" sentinel values before forwarding to guacd
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rawOptions)) {
|
||||
if (value !== "auto") options[key] = value;
|
||||
}
|
||||
|
||||
if (!type || !hostname) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing required fields: type and hostname" });
|
||||
}
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(type)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid connection type. Must be rdp, vnc, or telnet",
|
||||
});
|
||||
}
|
||||
|
||||
let token: string;
|
||||
|
||||
switch (type) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username || "",
|
||||
password || "",
|
||||
{
|
||||
port: port || 3389,
|
||||
domain,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token", error, {
|
||||
operation: "guac_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /guacamole/connect-host/{hostId}:
|
||||
* post:
|
||||
* summary: Generate Guacamole connection token from host configuration
|
||||
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
|
||||
* tags:
|
||||
* - Guacamole
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID to connect to
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* description: Override the host's default connection type
|
||||
* promptedUsername:
|
||||
* type: string
|
||||
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* promptedPassword:
|
||||
* type: string
|
||||
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: Encrypted connection token
|
||||
* guacamoleConnectionId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: Null until the WebSocket handshake completes. Query /guacamole/connection/{connectId} for the session-sharing ID.
|
||||
* termixConnectId:
|
||||
* type: string
|
||||
* description: Correlation ID for looking up this connection after the WebSocket opens.
|
||||
* 400:
|
||||
* description: Invalid request or unsupported connection type
|
||||
* 403:
|
||||
* description: Access denied to host
|
||||
* 404:
|
||||
* description: Host not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/connect-host/:hostId",
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
|
||||
const hostOwnerId =
|
||||
await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
const host = hostOwnerId
|
||||
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
|
||||
: null;
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
if (host.userId !== userId) {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
guacLogger.warn("User attempted to access host without permission", {
|
||||
operation: "guac_access_denied",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
}
|
||||
|
||||
const requestedProtocol = req.body?.protocol as string | undefined;
|
||||
const connectionType =
|
||||
requestedProtocol || (host.connectionType as string);
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
|
||||
return res.status(400).json({
|
||||
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
|
||||
// Apply the same migration fallback used in host.ts GET routes.
|
||||
const ct = host.connectionType as string;
|
||||
const rdpRaw = !!host.enableRdp;
|
||||
const vncRaw = !!host.enableVnc;
|
||||
const telRaw = !!host.enableTelnet;
|
||||
const isMigratedNonSsh =
|
||||
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
|
||||
const protocolEnabledMap: Record<string, boolean> = {
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
|
||||
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
|
||||
};
|
||||
if (!protocolEnabledMap[connectionType]) {
|
||||
return res.status(400).json({
|
||||
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
|
||||
});
|
||||
}
|
||||
|
||||
let guacConfig: Record<string, unknown> = {};
|
||||
if (host.guacamoleConfig) {
|
||||
try {
|
||||
guacConfig =
|
||||
typeof host.guacamoleConfig === "string"
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: (host.guacamoleConfig as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
guacLogger.warn("Failed to parse guacamole config", {
|
||||
operation: "guac_config_parse_error",
|
||||
hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
|
||||
// but guacd doesn't recognise "auto" as a valid parameter value.
|
||||
for (const key of Object.keys(guacConfig)) {
|
||||
if (guacConfig[key] === "auto") {
|
||||
delete guacConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract per-connection guacd proxy settings before passing the rest as connection settings
|
||||
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
|
||||
string | undefined;
|
||||
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
|
||||
const perConnectionGuacdPort = perConnectionGuacdPortRaw
|
||||
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
|
||||
: undefined;
|
||||
delete guacConfig["guacd-hostname"];
|
||||
delete guacConfig["guacd-port"];
|
||||
|
||||
if (guacConfig.dpi != null) {
|
||||
const parsed = parseInt(String(guacConfig.dpi), 10);
|
||||
guacConfig.dpi =
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
const hostRecord = host as Record<string, unknown>;
|
||||
const hostRepository = hostResolutionRepository;
|
||||
const isSharedConnection = host.userId !== userId;
|
||||
let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null;
|
||||
|
||||
if (isSharedConnection) {
|
||||
// Recipients never read the owner's raw secrets; wipe them and use
|
||||
// the per-recipient snapshot for the requested protocol instead.
|
||||
host.password = null;
|
||||
host.rdpUser = null;
|
||||
host.rdpPassword = null;
|
||||
host.vncUser = null;
|
||||
host.vncPassword = null;
|
||||
host.telnetUser = null;
|
||||
host.telnetPassword = null;
|
||||
|
||||
try {
|
||||
const resolution = await resolveRecipientSharedHostAuthentication(
|
||||
host,
|
||||
hostId,
|
||||
userId,
|
||||
connectionType as AuthOverrideProtocol,
|
||||
);
|
||||
sharedAuthResolution = resolution;
|
||||
const auth =
|
||||
resolution.source === "personal-override"
|
||||
? { ...resolution.credential, domain: null }
|
||||
: resolution.source === "owner-shared"
|
||||
? resolution.secret
|
||||
: null;
|
||||
if (auth) {
|
||||
if (connectionType === "rdp") {
|
||||
host.rdpUser = auth.username ?? null;
|
||||
host.rdpPassword = auth.password ?? null;
|
||||
if (auth.domain) host.rdpDomain = auth.domain;
|
||||
} else if (connectionType === "vnc") {
|
||||
host.vncUser = auth.username ?? null;
|
||||
host.vncPassword = auth.password ?? null;
|
||||
} else if (connectionType === "telnet") {
|
||||
host.telnetUser = auth.username ?? null;
|
||||
host.telnetPassword = auth.password ?? null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
guacLogger.warn("Failed to resolve shared host auth", {
|
||||
operation: "guac_shared_auth_resolve",
|
||||
hostId,
|
||||
protocol: connectionType,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
|
||||
const rdpEffectiveAuthType =
|
||||
(host.rdpAuthType as string) ||
|
||||
(host.rdpCredentialId ? "credential" : "direct");
|
||||
const vncEffectiveAuthType =
|
||||
(host.vncAuthType as string) ||
|
||||
(host.vncCredentialId ? "credential" : "direct");
|
||||
const telnetEffectiveAuthType =
|
||||
(host.telnetAuthType as string) ||
|
||||
(hostRecord.telnetCredentialId ? "credential" : "direct");
|
||||
|
||||
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
|
||||
try {
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
host.rdpCredentialId as number,
|
||||
host.userId as string,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.username) host.rdpUser = cred.username;
|
||||
if (cred.password) host.rdpPassword = cred.password;
|
||||
// domain is never sourced from credential
|
||||
}
|
||||
} catch (e) {
|
||||
guacLogger.warn("Failed to resolve RDP credential", {
|
||||
operation: "guac_rdp_credential_resolve",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
|
||||
try {
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
host.vncCredentialId as number,
|
||||
host.userId as string,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.password) host.vncPassword = cred.password;
|
||||
if (cred.username) host.vncUser = cred.username;
|
||||
}
|
||||
} catch (e) {
|
||||
guacLogger.warn("Failed to resolve VNC credential", {
|
||||
operation: "guac_vnc_credential_resolve",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
telnetEffectiveAuthType === "credential" &&
|
||||
hostRecord.telnetCredentialId
|
||||
) {
|
||||
try {
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
hostRecord.telnetCredentialId as number,
|
||||
host.userId as string,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.username) host.telnetUser = cred.username;
|
||||
if (cred.password) host.telnetPassword = cred.password;
|
||||
}
|
||||
} catch (e) {
|
||||
guacLogger.warn("Failed to resolve Telnet credential", {
|
||||
operation: "guac_telnet_credential_resolve",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token: string;
|
||||
let hostname = host.ip as string;
|
||||
let port = host.port as number;
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: host.rdpAuthType as string | null,
|
||||
credentialId: host.rdpCredentialId as number | null,
|
||||
sharedResolution: isSharedConnection ? sharedAuthResolution : undefined,
|
||||
});
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (rdpAuthTypeForConnect === "none") {
|
||||
username = String(req.body?.promptedUsername || "");
|
||||
password = String(req.body?.promptedPassword || "");
|
||||
} else {
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
}
|
||||
port = (host.rdpPort as number) || port || 3389;
|
||||
break;
|
||||
case "vnc":
|
||||
username = (host.vncUser as string) || "";
|
||||
password =
|
||||
(host.vncPassword as string) || (host.password as string) || "";
|
||||
port = (host.vncPort as number) || port || 5900;
|
||||
break;
|
||||
case "telnet":
|
||||
username = (host.telnetUser as string) || "";
|
||||
password =
|
||||
(host.telnetPassword as string) || (host.password as string) || "";
|
||||
port = (host.telnetPort as number) || port || 23;
|
||||
break;
|
||||
default:
|
||||
username = "";
|
||||
password = "";
|
||||
}
|
||||
const storedDomain =
|
||||
(host.rdpDomain as string) || (host.domain as string) || "";
|
||||
const domain = resolveRdpDomain(
|
||||
rdpAuthTypeForConnect,
|
||||
req.body?.promptedDomain,
|
||||
storedDomain,
|
||||
);
|
||||
|
||||
// Establish SSH tunnel if jump hosts are configured
|
||||
let jumpHosts: Array<{ hostId: number }> = [];
|
||||
if (host.jumpHosts) {
|
||||
try {
|
||||
jumpHosts =
|
||||
typeof host.jumpHosts === "string"
|
||||
? JSON.parse(host.jumpHosts as string)
|
||||
: (host.jumpHosts as Array<{ hostId: number }>);
|
||||
} catch {
|
||||
jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
let tunnelEndpoint: ReturnType<typeof resolveJumpTunnelEndpoint> | null =
|
||||
null;
|
||||
if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) {
|
||||
let guacdUrl: string | undefined;
|
||||
try {
|
||||
guacdUrl =
|
||||
(await createCurrentSettingsRepository().get("guac_url")) ??
|
||||
undefined;
|
||||
} catch {
|
||||
// Environment/default guacd configuration remains available.
|
||||
}
|
||||
const guacdHost =
|
||||
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
|
||||
tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
|
||||
}
|
||||
|
||||
if (jumpHosts.length > 0) {
|
||||
try {
|
||||
// The chain dials the first hop through that hop's own SOCKS5
|
||||
// settings; the target host's proxy config does not apply to it.
|
||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||
|
||||
if (!jumpClient) {
|
||||
guacLogger.error(
|
||||
"Failed to establish jump host chain for guacamole",
|
||||
undefined,
|
||||
{ operation: "guac_ssh_tunnel_error", hostId },
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish SSH tunnel to remote host",
|
||||
});
|
||||
}
|
||||
|
||||
const targetHostname = hostname;
|
||||
const targetPort = port;
|
||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer((sock) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
targetHostname,
|
||||
targetPort,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sock.destroy();
|
||||
return;
|
||||
}
|
||||
sock.pipe(stream).pipe(sock);
|
||||
},
|
||||
);
|
||||
});
|
||||
server.on("error", reject);
|
||||
server.listen(0, tunnelEndpoint!.bindHost, () => {
|
||||
const addr = server.address() as net.AddressInfo;
|
||||
// Auto-cleanup after 1 hour
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
jumpClient.end();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
hostname = tunnelEndpoint!.advertisedHost;
|
||||
port = tunnelPort;
|
||||
guacLogger.info("SSH tunnel established for guacamole", {
|
||||
operation: "guac_ssh_tunnel",
|
||||
hostId,
|
||||
tunnelPort,
|
||||
});
|
||||
} catch (tunnelError) {
|
||||
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
|
||||
operation: "guac_ssh_tunnel_error",
|
||||
hostId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish SSH tunnel to remote host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionType === "vnc" && !username) {
|
||||
try {
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: hostname,
|
||||
targetPort: port,
|
||||
bindHost: tunnelEndpoint!.bindHost,
|
||||
});
|
||||
hostname = tunnelEndpoint!.advertisedHost;
|
||||
port = proxy.port;
|
||||
setTimeout(proxy.close, 60 * 60 * 1000).unref();
|
||||
guacLogger.info("VNC compatibility proxy established", {
|
||||
operation: "guac_vnc_compatibility_proxy",
|
||||
hostId,
|
||||
proxyPort: port,
|
||||
});
|
||||
} catch (proxyError) {
|
||||
guacLogger.error(
|
||||
"Failed to establish VNC compatibility proxy",
|
||||
proxyError,
|
||||
{
|
||||
operation: "guac_vnc_compatibility_proxy_error",
|
||||
hostId,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish VNC compatibility proxy",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const guacdOverrides = {
|
||||
...(perConnectionGuacdHost
|
||||
? { guacdHost: perConnectionGuacdHost }
|
||||
: {}),
|
||||
...(perConnectionGuacdPort
|
||||
? { guacdPort: perConnectionGuacdPort }
|
||||
: {}),
|
||||
};
|
||||
const recordingEnabled =
|
||||
connectionType !== "vnc" && host.enableSessionLogging !== false;
|
||||
const recordingName = `${crypto.randomUUID()}.guac`;
|
||||
const recordingPath =
|
||||
process.env.GUACD_RECORDING_PATH ||
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
|
||||
const recordingMetadata = recordingEnabled
|
||||
? {
|
||||
hostId,
|
||||
userId,
|
||||
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||
path: recordingName,
|
||||
guacdPath: recordingPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
if (recordingEnabled) {
|
||||
guacConfig = withRecordingSettings(
|
||||
guacConfig,
|
||||
recordingPath,
|
||||
recordingName,
|
||||
);
|
||||
}
|
||||
|
||||
const termixConnectId = crypto.randomUUID();
|
||||
const termixMeta = {
|
||||
termixConnectId,
|
||||
hostId,
|
||||
ownerUserId: userId,
|
||||
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||
};
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
guacConfig = withDriveSettings(guacConfig, userId);
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
buildRdpSettings({
|
||||
port,
|
||||
domain,
|
||||
security:
|
||||
(host.rdpSecurity as string) ||
|
||||
(host.security as string) ||
|
||||
undefined,
|
||||
ignoreCert:
|
||||
host.rdpIgnoreCert !== undefined
|
||||
? !!host.rdpIgnoreCert
|
||||
: host.ignoreCert !== undefined
|
||||
? !!host.ignoreCert
|
||||
: true,
|
||||
guacConfig,
|
||||
guacdOverrides,
|
||||
}),
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: `${connectionType}_connect`,
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: `${hostname}:${port}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
termixConnectId,
|
||||
guacamoleConnectionId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||
operation: "guac_host_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /guacamole/status
|
||||
* Check if guacd is reachable
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
dbUrl =
|
||||
(await createCurrentSettingsRepository().get("guac_url")) ?? undefined;
|
||||
} catch {
|
||||
// Fall back to env vars
|
||||
}
|
||||
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
|
||||
|
||||
const net = await import("net");
|
||||
|
||||
const checkConnection = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(3000);
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(guacdPort, guacdHost);
|
||||
});
|
||||
};
|
||||
|
||||
const isConnected = await checkConnection();
|
||||
|
||||
res.json({
|
||||
guacd: {
|
||||
host: guacdHost,
|
||||
port: guacdPort,
|
||||
status: isConnected ? "connected" : "disconnected",
|
||||
},
|
||||
websocket: {
|
||||
port: 30008,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to check guacamole status", error, {
|
||||
operation: "guac_status_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,251 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
join?: string;
|
||||
readOnly?: boolean;
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
settings: {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"disable-auth"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
path: string;
|
||||
/** Directory guacd was told to write into; differs from the backend's view
|
||||
* when guacd runs in its own container. */
|
||||
guacdPath?: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(jwtSecret + "_guacamole")
|
||||
.digest();
|
||||
}
|
||||
|
||||
guacLogger.warn(
|
||||
"No persistent encryption key found, generating random key",
|
||||
{
|
||||
operation: "guac_key_generation",
|
||||
},
|
||||
);
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify(tokenObject),
|
||||
"utf8",
|
||||
"base64",
|
||||
);
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
operation: "guac_token_decrypt_error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createRdpToken(
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
...(!username && !password ? { "disable-auth": true } : {}),
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
password,
|
||||
port: 5900,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createTelnetToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
// join tokens never carry recording params - only the primary connection's
|
||||
// token should write recording-path/recording-name to guacd.
|
||||
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
join: guacamoleConnectionId,
|
||||
readOnly,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authFailureTracker } from "./metrics/state.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import type { WebSocket } from "ws";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
@@ -328,7 +328,7 @@ export class SSHHostKeyVerifier {
|
||||
algorithm,
|
||||
currentChangeCount,
|
||||
);
|
||||
authFailureTracker.resetHostKeyFailure(hostId);
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId });
|
||||
}
|
||||
|
||||
private static async promptUserForNewKey(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { parseSshOptions } from "./ssh-options.js";
|
||||
import { findUsableCredential } from "./usable-credential.js";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
|
||||
@@ -13,10 +13,9 @@ import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-a
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
expandExternalUsername,
|
||||
} from "./credential-username.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import type { HostAction } from "../utils/permission-manager.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
@@ -97,9 +96,6 @@ export async function resolveHostById(
|
||||
if (!ownerEquivalent) {
|
||||
// Owner-only operational secrets are never shared.
|
||||
host.sudoPassword = null;
|
||||
host.autostartPassword = null;
|
||||
host.autostartKey = null;
|
||||
host.autostartKeyPassword = null;
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
@@ -110,20 +106,6 @@ export async function resolveHostById(
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.tunnelConnections === "string") {
|
||||
try {
|
||||
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
|
||||
} catch {
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.statsConfig === "string" && host.statsConfig) {
|
||||
try {
|
||||
host.statsConfig = JSON.parse(host.statsConfig as string);
|
||||
} catch {
|
||||
host.statsConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
@@ -132,16 +114,25 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
if (
|
||||
!ownerEquivalent &&
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
) {
|
||||
host.terminalConfig = {
|
||||
...(host.terminalConfig as Record<string, unknown>),
|
||||
sudoPassword: null,
|
||||
};
|
||||
// 2.8 editors kept the sudo password inside terminal_config. It is only
|
||||
// ever handed out as sudoPassword, and only to the owner.
|
||||
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
|
||||
host.sudoPassword = legacySudo;
|
||||
}
|
||||
host.terminalConfig = rest;
|
||||
}
|
||||
// A row the boot copy has not reached yet still has them in terminal_config.
|
||||
host.sshOptions = parseSshOptions(
|
||||
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
|
||||
);
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
@@ -149,13 +140,6 @@ export async function resolveHostById(
|
||||
host.socks5ProxyChain = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.quickActions === "string" && host.quickActions) {
|
||||
try {
|
||||
host.quickActions = JSON.parse(host.quickActions as string);
|
||||
} catch {
|
||||
host.quickActions = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.portKnockSequence === "string" && host.portKnockSequence) {
|
||||
try {
|
||||
host.portKnockSequence = JSON.parse(host.portKnockSequence as string);
|
||||
@@ -226,7 +210,7 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
|
||||
host.username = await expandOidcUsername(
|
||||
host.username = await expandExternalUsername(
|
||||
host.username as string | undefined,
|
||||
ownerEquivalent ? ownerId : userId,
|
||||
);
|
||||
@@ -239,24 +223,19 @@ export async function resolveHostById(
|
||||
sharedAuthResolution === "recipient-override" ? userId : ownerId,
|
||||
);
|
||||
|
||||
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
|
||||
// certificate itself is obtained per-user at connect time via Vault OIDC.
|
||||
if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") {
|
||||
try {
|
||||
const profile = await createCurrentVaultProfileRepository().findById(
|
||||
host.vaultProfileId as number,
|
||||
);
|
||||
if (profile) {
|
||||
(host as Record<string, unknown>).vaultProfile = profile;
|
||||
host.authType = "vault";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve vault profile for host", {
|
||||
operation: "host_resolver_vault_profile",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
// Keyboard-interactive handlers run synchronously mid-handshake and can
|
||||
// only read their host settings from here.
|
||||
try {
|
||||
const { loadHostPluginSettings } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
const settings = (await loadHostPluginSettings([hostId])).get(hostId);
|
||||
if (settings) host.pluginSettings = settings;
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to load plugin settings for host", {
|
||||
operation: "host_resolver_plugin_settings",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
|
||||
return host as unknown as SSHHost;
|
||||
@@ -336,29 +315,3 @@ async function resolveRecipientSshAuth(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: HostAction = "connect",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
requiredPermission,
|
||||
);
|
||||
return accessInfo.hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HostSessionStatus } from "./host-session-status.js";
|
||||
|
||||
describe("HostSessionStatus", () => {
|
||||
it("reports only the first connection and last disconnection per host", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const closeFirst = status.register(7);
|
||||
const closeSecond = status.register(7);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
closeFirst();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes connection cleanup idempotent", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const close = status.register(9);
|
||||
close();
|
||||
close();
|
||||
|
||||
expect(listener.mock.calls).toEqual([
|
||||
[9, true],
|
||||
[9, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
|
||||
type HostSessionStatusListener = (hostId: number, online: boolean) => void;
|
||||
|
||||
export interface HostSessionStatusPayload {
|
||||
hostId: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export class HostSessionStatus {
|
||||
private counts = new Map<number, number>();
|
||||
private listeners = new Set<HostSessionStatusListener>();
|
||||
@@ -32,6 +39,12 @@ export class HostSessionStatus {
|
||||
|
||||
private emit(hostId: number, online: boolean): void {
|
||||
for (const listener of this.listeners) listener(hostId, online);
|
||||
// Also published as a topic so plugins (and anything else on the bus) can
|
||||
// observe session status without holding a reference to this singleton.
|
||||
pluginEvents.emit(TOPICS.hostSessionStatus, {
|
||||
hostId,
|
||||
online,
|
||||
} satisfies HostSessionStatusPayload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* One-line hand-off from any core feature to anything listening for a generic
|
||||
* named event (host_deleted, user_login, ...). Fire-and-forget: it publishes
|
||||
* onto the plugin event bus under "internal.event" and never waits on a
|
||||
* listener. Plugins subscribe through ctx.events.
|
||||
*/
|
||||
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
|
||||
export interface InternalEventPayload {
|
||||
event: string;
|
||||
userId: string;
|
||||
hostId?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function emitInternalEvent(
|
||||
event: string,
|
||||
userId: string,
|
||||
hostId?: number,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!userId) return;
|
||||
pluginEvents.emit(TOPICS.internalEvent, {
|
||||
event,
|
||||
userId,
|
||||
hostId,
|
||||
details,
|
||||
} satisfies InternalEventPayload);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Auth types whose SSH certificate is issued on demand through a browser
|
||||
* sign-in and cached per user and host (opkssh_tokens). They share the
|
||||
* whole connect path; only the issuing flow differs.
|
||||
*/
|
||||
export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const;
|
||||
|
||||
export function usesIssuedCertificate(authType: string | null | undefined) {
|
||||
return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes(
|
||||
authType ?? "",
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
||||
import {
|
||||
createAutoKeyboardInteractiveHandler,
|
||||
createPromptKeyboardInteractiveHandler,
|
||||
} from "./connect/keyboard-interactive.js";
|
||||
import type { SshConnectHost, SshPromptChannel } from "./connect/types.js";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
|
||||
type JumpHostConfig = {
|
||||
@@ -48,7 +50,7 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
export class JumpHostChainError extends Error {
|
||||
class JumpHostChainError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly hopIndex: number,
|
||||
@@ -62,6 +64,7 @@ export class JumpHostChainError extends Error {
|
||||
export async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
prompt?: SshPromptChannel,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
@@ -69,6 +72,12 @@ export async function createJumpHostChain(
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const clients: SSHClient[] = [];
|
||||
let closed = false;
|
||||
const closeChain = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const client of clients) client.end();
|
||||
};
|
||||
|
||||
try {
|
||||
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
||||
@@ -88,7 +97,7 @@ export async function createJumpHostChain(
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
closeChain();
|
||||
throw new JumpHostChainError(
|
||||
`Jump host ${i + 1} of ${totalHops} was not found`,
|
||||
i,
|
||||
@@ -109,19 +118,12 @@ export async function createJumpHostChain(
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (closed) throw new Error("Jump host chain closed");
|
||||
const jumpHostConfig = jumpHostConfigs[i]!;
|
||||
|
||||
const jumpClient = new SSHClient();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
jumpClient.once("close", closeChain);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
@@ -138,6 +140,11 @@ export async function createJumpHostChain(
|
||||
jumpClient.end();
|
||||
}, readyTimeoutMs + 5000);
|
||||
|
||||
jumpClient.once("close", () => {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error("Jump host connection closed");
|
||||
resolve(false);
|
||||
});
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
@@ -167,100 +174,59 @@ export async function createJumpHostChain(
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: readyTimeoutMs,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
// Each hop goes through the same pipeline as the target, so hops get
|
||||
// every auth type, not just password, key and agent.
|
||||
const built = await buildConnectConfig(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
{
|
||||
userId,
|
||||
purpose: "jump-host",
|
||||
profile: "jump",
|
||||
client: jumpClient,
|
||||
interactive: !!prompt,
|
||||
hostKeySocket: prompt?.hostKeySocket ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
try {
|
||||
connectConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
jumpHostConfig.key,
|
||||
jumpHostConfig.keyPassword,
|
||||
);
|
||||
} catch (keyError) {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error(
|
||||
`Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`,
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
} else if (jumpHostConfig.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
connectConfig,
|
||||
jumpHostConfig.terminalConfig as
|
||||
Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
);
|
||||
if (closed) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (built.outcome.status !== "ready") {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error(
|
||||
`Jump host ${i + 1}/${totalHops}: ${built.outcome.message}`,
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const connectConfig = built.config;
|
||||
connectConfig.readyTimeout = readyTimeoutMs;
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
prompt
|
||||
? createPromptKeyboardInteractiveHandler(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
{
|
||||
...prompt,
|
||||
ask: (request) =>
|
||||
prompt.ask(
|
||||
request.kind === "browser"
|
||||
? {
|
||||
...request,
|
||||
instructions: `Jump host ${i + 1}/${totalHops} (${jumpHostConfig.ip}): ${request.instructions}`,
|
||||
}
|
||||
: {
|
||||
...request,
|
||||
prompt: `Jump host ${i + 1}/${totalHops} (${jumpHostConfig.ip}): ${request.prompt}`,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
: createAutoKeyboardInteractiveHandler(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
),
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
@@ -289,7 +255,7 @@ export async function createJumpHostChain(
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
closeChain();
|
||||
throw new JumpHostChainError(
|
||||
getErrorMessage(
|
||||
lastError,
|
||||
@@ -309,7 +275,7 @@ export async function createJumpHostChain(
|
||||
fileLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
closeChain();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
|
||||
describe("getJumpHostSocks5Config", () => {
|
||||
it("uses the first jump host proxy settings", () => {
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
socks5ProxyChain: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use destination proxy settings for the first jump host", () => {
|
||||
expect(getJumpHostSocks5Config({ useSocks5: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a serialized proxy chain from the first jump host", () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
name: "Proxy 1",
|
||||
host: "proxy.internal",
|
||||
port: 1080,
|
||||
type: "socks5" as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: JSON.stringify(chain),
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: undefined,
|
||||
socks5Port: undefined,
|
||||
socks5Username: undefined,
|
||||
socks5Password: undefined,
|
||||
socks5ProxyChain: chain,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* What core needs from live SSH terminal sessions, through the plugin
|
||||
* service "sessions.live" (the "ssh" provider) rather than an import.
|
||||
*
|
||||
* The open-tabs route lists the caller's sessions and ties saved tabs to the
|
||||
* terminal's idle timeout. With no provider (the terminal is off) a lookup
|
||||
* finds nothing, because "no live session" and "the terminal is off" mean the
|
||||
* same thing there.
|
||||
*/
|
||||
|
||||
import { getServiceImplementation } from "../plugins/service-registry.js";
|
||||
|
||||
const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
const SSH_PROVIDER = "ssh";
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
lastDetachedAt: number | null;
|
||||
tabInstanceId: string | null;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface LiveSessionsV1 {
|
||||
listForUser: (userId: string) => LiveSessionInfo[];
|
||||
idleTimeoutMinutes: () => number;
|
||||
}
|
||||
|
||||
function provider(): LiveSessionsV1 | undefined {
|
||||
return getServiceImplementation<LiveSessionsV1>(
|
||||
LIVE_SESSIONS_SERVICE,
|
||||
"^1.0.0",
|
||||
SSH_PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
export const liveTerminalSessions = {
|
||||
listForUser(userId: string): LiveSessionInfo[] {
|
||||
return provider()?.listForUser(userId) ?? [];
|
||||
},
|
||||
/** How long the terminal keeps a detached session, or null while it is off. */
|
||||
idleTimeoutMinutes(): number | null {
|
||||
return provider()?.idleTimeoutMinutes() ?? null;
|
||||
},
|
||||
};
|
||||
@@ -1,373 +0,0 @@
|
||||
import { createCurrentAlertRepository } from "../../database/repositories/factory.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
sendNotification,
|
||||
type AlertPayload,
|
||||
type NotificationChannel,
|
||||
} from "../../utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../utils/discord-sender.js";
|
||||
|
||||
type AlertTriggerType =
|
||||
| "host_offline"
|
||||
| "host_online"
|
||||
| "cpu_threshold"
|
||||
| "memory_threshold"
|
||||
| "disk_threshold"
|
||||
| "health_check_failure"
|
||||
| "health_check_recovery"
|
||||
| "user_login";
|
||||
|
||||
interface AlertRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: AlertTriggerType;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set once the alert rules have been copied into automations. From then on the
|
||||
* automations engine owns evaluation, and this one stands down rather than
|
||||
* sending a second notification for every rule.
|
||||
*
|
||||
* The class is left in place so an install that has not migrated yet, or one
|
||||
* rolled back, still alerts exactly as before.
|
||||
*/
|
||||
let supersededByAutomations = false;
|
||||
|
||||
export function markAlertEngineSuperseded(): void {
|
||||
supersededByAutomations = true;
|
||||
}
|
||||
|
||||
export function isAlertEngineSuperseded(): boolean {
|
||||
return supersededByAutomations;
|
||||
}
|
||||
|
||||
export class AlertEngine {
|
||||
private static instance: AlertEngine;
|
||||
|
||||
// "${ruleId}:${hostId}" → lastFiredAt ms
|
||||
private cooldownMap = new Map<string, number>();
|
||||
// "${ruleId}:${hostId}" → breachStartTime ms
|
||||
private breachStartMap = new Map<string, number>();
|
||||
// hostId → last known status
|
||||
private lastStatusMap = new Map<number, "online" | "offline">();
|
||||
// "${hostId}:${checkId}" → last known ok state
|
||||
private healthCheckStateMap = new Map<string, boolean>();
|
||||
|
||||
static getInstance(): AlertEngine {
|
||||
if (!AlertEngine.instance) {
|
||||
AlertEngine.instance = new AlertEngine();
|
||||
}
|
||||
return AlertEngine.instance;
|
||||
}
|
||||
|
||||
async evaluateMetrics(
|
||||
hostId: number,
|
||||
metrics: {
|
||||
cpu?: { percent: number | null } | null;
|
||||
memory?: { percent: number | null } | null;
|
||||
disk?: { percent: number | null } | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter((r) =>
|
||||
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
|
||||
r.triggerType,
|
||||
),
|
||||
);
|
||||
|
||||
if (rules.length === 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const rule of rules) {
|
||||
let currentValue: number | null | undefined;
|
||||
if (rule.triggerType === "cpu_threshold")
|
||||
currentValue = metrics.cpu?.percent;
|
||||
else if (rule.triggerType === "memory_threshold")
|
||||
currentValue = metrics.memory?.percent;
|
||||
else if (rule.triggerType === "disk_threshold")
|
||||
currentValue = metrics.disk?.percent;
|
||||
|
||||
if (currentValue == null || rule.thresholdValue == null) continue;
|
||||
|
||||
const key = `${rule.id}:${hostId}`;
|
||||
const isBreaching = currentValue >= rule.thresholdValue;
|
||||
|
||||
if (isBreaching) {
|
||||
if (!this.breachStartMap.has(key)) {
|
||||
this.breachStartMap.set(key, now);
|
||||
}
|
||||
const breachStart = this.breachStartMap.get(key)!;
|
||||
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
|
||||
if (
|
||||
now - breachStart >= durationMs &&
|
||||
!(await this.isCoolingDown(rule.id, hostId))
|
||||
) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
value: currentValue,
|
||||
message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`,
|
||||
severity: currentValue >= 95 ? "critical" : "warning",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.breachStartMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateStatus(hostId: number, isOnline: boolean): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const currentStatus = isOnline ? "online" : "offline";
|
||||
const lastStatus = this.lastStatusMap.get(hostId);
|
||||
|
||||
if (lastStatus === currentStatus) return;
|
||||
this.lastStatusMap.set(hostId, currentStatus);
|
||||
|
||||
if (lastStatus === undefined) return;
|
||||
|
||||
const triggerType: AlertTriggerType = isOnline
|
||||
? "host_online"
|
||||
: "host_offline";
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: isOnline
|
||||
? `Host "${hostName}" is back online`
|
||||
: `Host "${hostName}" is offline`,
|
||||
severity: isOnline ? "info" : "critical",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateHealthCheck(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
checkId: string,
|
||||
ok: boolean,
|
||||
detail?: string,
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const stateKey = `${hostId}:${checkId}`;
|
||||
const lastOk = this.healthCheckStateMap.get(stateKey);
|
||||
this.healthCheckStateMap.set(stateKey, ok);
|
||||
|
||||
if (lastOk === undefined) return;
|
||||
|
||||
let triggerType: AlertTriggerType | null = null;
|
||||
if (!ok && lastOk) triggerType = "health_check_failure";
|
||||
else if (ok && !lastOk) triggerType = "health_check_recovery";
|
||||
|
||||
if (!triggerType) return;
|
||||
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: ok
|
||||
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
|
||||
: `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`,
|
||||
severity: ok ? "info" : "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateUserLogin(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === "user_login",
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fireAlert(
|
||||
rule: AlertRule,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
context: {
|
||||
value?: number;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
},
|
||||
): Promise<void> {
|
||||
this.markCooldown(rule.id, hostId);
|
||||
|
||||
const payload: AlertPayload = {
|
||||
hostName,
|
||||
hostId,
|
||||
triggerType: rule.triggerType,
|
||||
value: context.value,
|
||||
threshold: rule.thresholdValue ?? undefined,
|
||||
message: context.message,
|
||||
severity: context.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
};
|
||||
|
||||
try {
|
||||
const repository = createCurrentAlertRepository();
|
||||
await repository.createFiring({
|
||||
userId: rule.userId,
|
||||
ruleId: rule.id,
|
||||
hostId,
|
||||
hostName,
|
||||
value: context.value ?? null,
|
||||
message: context.message,
|
||||
severity: context.severity,
|
||||
});
|
||||
|
||||
await repository.pruneFiringsOlderThan(rule.userId, 30);
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to write alert firing", {
|
||||
operation: "alert_firing_insert_error",
|
||||
ruleId: rule.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const channels = await this.loadChannelsForRule(rule.id);
|
||||
for (const channel of channels) {
|
||||
if (channel.type === "discord") {
|
||||
// parse config and send via Discord sender directly
|
||||
try {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
statsLogger.warn("Failed to parse discord channel config", {
|
||||
operation: "discord_config_parse_error",
|
||||
channelId: channel.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
sendDiscord(parsed as any, payload).catch((err) => {
|
||||
statsLogger.warn("Failed to send discord notification", {
|
||||
operation: "discord_notification_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to send discord notification", {
|
||||
operation: "discord_notification_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sendNotification(channel, payload).catch((err) => {
|
||||
statsLogger.warn("Failed to send notification", {
|
||||
operation: "notification_delivery_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCoolingDown(
|
||||
ruleId: number,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
const key = `${ruleId}:${hostId}`;
|
||||
const lastFired = this.cooldownMap.get(key);
|
||||
if (!lastFired) return false;
|
||||
const rule = await this.loadRuleById(ruleId);
|
||||
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
|
||||
return Date.now() - lastFired < cooldownMs;
|
||||
}
|
||||
|
||||
private markCooldown(ruleId: number, hostId: number): void {
|
||||
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
|
||||
}
|
||||
|
||||
private async loadRulesForHost(hostId: number): Promise<AlertRule[]> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHost(
|
||||
hostId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertRule[]> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHostUser(
|
||||
hostId,
|
||||
userId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRuleById(ruleId: number): Promise<AlertRule | null> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().findRuleById(
|
||||
ruleId,
|
||||
)) as AlertRule | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<NotificationChannel[]> {
|
||||
try {
|
||||
return await createCurrentAlertRepository().listEnabledChannelsForRule(
|
||||
ruleId,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getHostName(hostId: number): Promise<string> {
|
||||
try {
|
||||
return (
|
||||
(await createCurrentAlertRepository().getHostDisplayName(hostId)) ??
|
||||
`Host #${hostId}`
|
||||
);
|
||||
} catch {
|
||||
return `Host #${hostId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* One-line hand-off from the metrics poller to the automations engine.
|
||||
*
|
||||
* The poller is already a very large module, so the hooks it calls live here
|
||||
* instead. Everything is fire-and-forget and imported lazily: a failure in the
|
||||
* automations layer must never disturb metric collection, and a static import
|
||||
* would create a cycle (automations reads repositories, which the metrics
|
||||
* module also pulls in).
|
||||
*
|
||||
* For the generic (non-metrics) internal event notifier, see
|
||||
* `hosts/automation-events.ts` -- that one is shared across features.
|
||||
*/
|
||||
|
||||
import type { MetricsSnapshot } from "../../automations/conditions.js";
|
||||
|
||||
export function notifyAutomationMetrics(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
metrics: MetricsSnapshot,
|
||||
): void {
|
||||
if (!ownerUserId) return;
|
||||
import("../../automations/triggers.js")
|
||||
.then((triggers) => triggers.onMetrics({ hostId, ownerUserId, metrics }))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function notifyAutomationStatus(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
online: boolean,
|
||||
): void {
|
||||
if (!ownerUserId) return;
|
||||
import("../../automations/triggers.js")
|
||||
.then((triggers) => triggers.onStatus({ hostId, ownerUserId, online }))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function notifyAutomationHealthCheck(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
checkId: string,
|
||||
ok: boolean,
|
||||
detail?: string,
|
||||
): void {
|
||||
if (!userId) return;
|
||||
import("../../automations/triggers.js")
|
||||
.then((triggers) =>
|
||||
triggers.onHealthCheck({ hostId, userId, checkId, ok, detail }),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
authType?: string;
|
||||
};
|
||||
|
||||
export type TcpPingStatsConfig = {
|
||||
statusCheckEnabled: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
};
|
||||
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || usesIssuedCertificate(host.authType))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
}
|
||||
|
||||
export function parseStatusHostIds(value: unknown): Set<number> | null {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return new Set();
|
||||
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
|
||||
type HistoryRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const RANGE_OFFSETS: Record<string, number> = {
|
||||
"1h": 1 * 60 * 60 * 1000,
|
||||
"6h": 6 * 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
export function registerHostMetricsHistoryRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/history/{id}:
|
||||
* get:
|
||||
* summary: Get historical metrics for a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: range
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [1h, 6h, 24h, 7d, 30d]
|
||||
* - in: query
|
||||
* name: from
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* - in: query
|
||||
* name: to
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of metric history rows.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
* 404:
|
||||
* description: Host not found or no access.
|
||||
*/
|
||||
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
|
||||
const hostId = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const hasAccess = await canAccessHost(userId, hostId, "connect");
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
const { range, from, to } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
let fromTs: string;
|
||||
let toTs: string = new Date().toISOString();
|
||||
|
||||
if (range) {
|
||||
const offsetMs = RANGE_OFFSETS[range];
|
||||
if (!offsetMs) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
|
||||
}
|
||||
fromTs = new Date(Date.now() - offsetMs).toISOString();
|
||||
} else if (from && to) {
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid from/to date format" });
|
||||
}
|
||||
fromTs = fromDate.toISOString();
|
||||
toTs = toDate.toISOString();
|
||||
} else {
|
||||
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
|
||||
// Normalize both sides to that format for reliable string comparison.
|
||||
const toSqlite = (iso: string) =>
|
||||
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
const rows = (
|
||||
await createCurrentHostMetricsHistoryRepository().listRange(
|
||||
hostId,
|
||||
toSqlite(fromTs),
|
||||
toSqlite(toTs),
|
||||
)
|
||||
).map((row) => ({
|
||||
ts: row.ts,
|
||||
cpu_percent: row.cpuPercent,
|
||||
mem_percent: row.memPercent,
|
||||
disk_percent: row.diskPercent,
|
||||
net_rx_bytes: row.netRxBytes,
|
||||
net_tx_bytes: row.netTxBytes,
|
||||
}));
|
||||
|
||||
res.json({ rows, fromTs, toTs });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch metrics history", {
|
||||
operation: "metrics_history_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch metrics history" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "./host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
"Host denied (verification failed)",
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
])("classifies %s", (message) => {
|
||||
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary authentication failures", () => {
|
||||
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("does not call a TCP-reachable host online before authentication", () => {
|
||||
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("keeps a verified host online across later reachability checks", () => {
|
||||
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,127 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
raw: string;
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
|
||||
|
||||
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
|
||||
export function parseCrontab(output: string): CronEntry[] {
|
||||
const entries: CronEntry[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
if (!line.trim()) continue;
|
||||
// A commented-out job: "# <schedule> <command>" (our toggle convention).
|
||||
const disabled = line.match(
|
||||
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
|
||||
);
|
||||
if (disabled) {
|
||||
const { schedule, command } = splitScheduleCommand(disabled[1]);
|
||||
if (command) {
|
||||
entries.push({ raw: line, enabled: false, schedule, command });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip pure comments / env assignments.
|
||||
if (line.trimStart().startsWith("#")) continue;
|
||||
if (/^\s*[A-Z_]+=/.test(line)) continue;
|
||||
const { schedule, command } = splitScheduleCommand(line.trim());
|
||||
if (!command) continue;
|
||||
entries.push({ raw: line, enabled: true, schedule, command });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function splitScheduleCommand(line: string): {
|
||||
schedule: string;
|
||||
command: string;
|
||||
} {
|
||||
if (line.startsWith("@")) {
|
||||
const [sched, ...rest] = line.split(/\s+/);
|
||||
return { schedule: sched, command: rest.join(" ") };
|
||||
}
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return { schedule: "", command: "" };
|
||||
return {
|
||||
schedule: parts.slice(0, 5).join(" "),
|
||||
command: parts.slice(5).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_SCHEDULE_RE =
|
||||
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
|
||||
|
||||
export function isValidCronSchedule(schedule: string): boolean {
|
||||
return CRON_SCHEDULE_RE.test(schedule.trim());
|
||||
}
|
||||
|
||||
/** Serialize entries into a full crontab body (toggled entries are commented). */
|
||||
export function serializeCrontab(entries: CronEntry[]): string {
|
||||
const lines = entries.map((e) => {
|
||||
const body = `${e.schedule} ${e.command}`.trim();
|
||||
return e.enabled ? body : `# ${body}`;
|
||||
});
|
||||
return lines.join("\n") + (lines.length ? "\n" : "");
|
||||
}
|
||||
|
||||
/** Build a command that atomically replaces the user crontab from the given body. */
|
||||
export function buildApplyCrontabCommand(body: string): string {
|
||||
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
const { entries } = req.body as { entries?: CronEntry[] };
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new ManagerInputError("entries must be an array");
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (typeof e?.command !== "string" || !e.command.trim()) {
|
||||
throw new ManagerInputError("Each entry needs a command");
|
||||
}
|
||||
if (e.command.includes("\n")) {
|
||||
throw new ManagerInputError("Commands cannot contain newlines");
|
||||
}
|
||||
if (!isValidCronSchedule(String(e.schedule))) {
|
||||
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
|
||||
}
|
||||
}
|
||||
const body = serializeCrontab(entries);
|
||||
const { stdout, stderr, code } = await execCommand(
|
||||
client,
|
||||
buildApplyCrontabCommand(body),
|
||||
15000,
|
||||
);
|
||||
return { success: code === 0, output: stdout || stderr };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
|
||||
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
|
||||
|
||||
export class ElevationError extends Error {
|
||||
code: ElevationErrorCode;
|
||||
constructor(code: ElevationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ElevationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElevatedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
usedSudo: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED = [
|
||||
"permission denied",
|
||||
"operation not permitted",
|
||||
"must be run as root",
|
||||
"must be superuser",
|
||||
"you need to be root",
|
||||
"are you root",
|
||||
"access denied",
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases sudo itself prints to STDERR when authentication or authorization
|
||||
* fails. We only match these against sudo's stderr (never command output), so a
|
||||
* command that happens to print "permission denied" on stdout is not mistaken
|
||||
* for a sudo failure.
|
||||
*/
|
||||
const SUDO_AUTH_FAILED = [
|
||||
"incorrect password",
|
||||
"a password is required",
|
||||
"a terminal is required",
|
||||
"no tty present",
|
||||
"sorry, try again",
|
||||
"no password was provided",
|
||||
"1 incorrect password attempt",
|
||||
];
|
||||
|
||||
const SUDO_NOT_SUDOER = [
|
||||
"is not in the sudoers file",
|
||||
"not allowed to run sudo",
|
||||
"not allowed to execute",
|
||||
];
|
||||
|
||||
/**
|
||||
* Marker printed only after sudo has successfully authenticated and started the
|
||||
* inner shell. Its presence on stdout is the authoritative "elevation worked"
|
||||
* signal; its absence (together with sudo stderr) means auth failed.
|
||||
*/
|
||||
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
|
||||
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
|
||||
|
||||
/** Escape a value for single-quoted shell context. */
|
||||
export function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the elevated command string:
|
||||
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
|
||||
*
|
||||
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
|
||||
* command's own output stays clean and sudo's auth errors stay on stderr. The
|
||||
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
|
||||
* genuine auth failure from command output that merely contains scary words.
|
||||
*/
|
||||
export function buildSudoCommand(
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): string {
|
||||
const pw = shellSingleQuote(sudoPassword);
|
||||
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
|
||||
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
|
||||
}
|
||||
|
||||
function includesAny(text: string, needles: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return needles.some((n) => lower.includes(n));
|
||||
}
|
||||
|
||||
function looksLikePermissionDenied(text: string): boolean {
|
||||
return includesAny(text, PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
/** Strip the success marker line from the front of stdout. */
|
||||
function stripMarker(stdout: string): string {
|
||||
return stdout.replace(SUDO_OK_LINE_RE, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on a pooled client, elevating with the host's stored sudo
|
||||
* password only when needed (or when forced). Throws a typed `ElevationError`
|
||||
* when elevation is required but unavailable/incorrect.
|
||||
*/
|
||||
export async function execElevated(
|
||||
client: Client,
|
||||
command: string,
|
||||
sudoPassword: string | undefined,
|
||||
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
|
||||
): Promise<ElevatedResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||
|
||||
if (!opts.forceSudo) {
|
||||
const direct = await execCommand(client, command, timeoutMs);
|
||||
if (direct.code === 0) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
// Only escalate when the failure looks like a privilege problem. A
|
||||
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
|
||||
if (!looksLikePermissionDenied(direct.stderr)) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
} else if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
|
||||
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
|
||||
const result = await execCommand(client, sudoCmd, timeoutMs);
|
||||
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
|
||||
|
||||
if (!authenticated) {
|
||||
// Elevation never started: diagnose from sudo's stderr only.
|
||||
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
|
||||
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
|
||||
if (notSudoer) {
|
||||
throw new ElevationError(
|
||||
"NOT_SUDOER",
|
||||
"The connected user is not permitted to use sudo on this host.",
|
||||
);
|
||||
}
|
||||
if (authFailed) {
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
// No marker and no recognizable sudo error: treat as a generic failure but
|
||||
// keep the original output so the caller can surface it.
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stripMarker(result.stdout),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
usedSudo: true,
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidPort,
|
||||
isValidIpProtocol,
|
||||
isValidFirewallTarget,
|
||||
type IpProtocol,
|
||||
type FirewallTarget,
|
||||
} from "./validation.js";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface FirewallRuleSpec {
|
||||
protocol: IpProtocol;
|
||||
port: number;
|
||||
target: FirewallTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
|
||||
* specific dport with an explicit target, and never the chain policy, so an
|
||||
* existing ESTABLISHED/SSH rule is left intact.
|
||||
*/
|
||||
export function buildIptablesRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
const flag = op === "add" ? "-A" : "-D";
|
||||
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
|
||||
}
|
||||
|
||||
export function buildNftRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
// nftables uses the inet filter table's input chain by convention.
|
||||
const verb = op === "add" ? "add" : "delete";
|
||||
const action =
|
||||
spec.target.toLowerCase() === "reject"
|
||||
? "reject"
|
||||
: spec.target.toLowerCase();
|
||||
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
const { op, protocol, port, target } = req.body as {
|
||||
op?: "add" | "delete";
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
target?: string;
|
||||
};
|
||||
if (op !== "add" && op !== "delete") {
|
||||
throw new ManagerInputError("Invalid op");
|
||||
}
|
||||
if (!isValidIpProtocol(protocol))
|
||||
throw new ManagerInputError("Invalid protocol");
|
||||
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
|
||||
if (!isValidFirewallTarget(target))
|
||||
throw new ManagerInputError("Invalid target");
|
||||
|
||||
const spec: FirewallRuleSpec = {
|
||||
protocol,
|
||||
port: Number(port),
|
||||
target,
|
||||
};
|
||||
const fw = await collectFirewallMetrics(client);
|
||||
const cmd =
|
||||
fw.type === "nftables"
|
||||
? buildNftRuleCommand(op, spec)
|
||||
: buildIptablesRuleCommand(op, spec);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
backend: fw.type,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
// Best-effort persistence across common tools.
|
||||
const cmd =
|
||||
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
|
||||
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
|
||||
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
pkg: platform.pkg,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../types/index.js";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { createCurrentHostHealthRepository } from "../../../database/repositories/factory.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import { isValidPort } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { AlertEngine } from "../alert-engine.js";
|
||||
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tcp" | "http";
|
||||
target: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
|
||||
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
|
||||
|
||||
export function isValidHealthCheck(c: unknown): c is HealthCheck {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const o = c as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || !o.id) return false;
|
||||
if (typeof o.name !== "string") return false;
|
||||
if (o.type !== "tcp" && o.type !== "http") return false;
|
||||
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
|
||||
if (o.type === "tcp" && !isValidPort(o.port)) return false;
|
||||
if (
|
||||
o.path !== undefined &&
|
||||
(typeof o.path !== "string" || !PATH_RE.test(o.path))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Build a command that runs the check from the host and prints "ok latency". */
|
||||
export function buildHealthCheckCommand(check: HealthCheck): string {
|
||||
if (check.type === "tcp") {
|
||||
const host = shellSingleQuote(check.target);
|
||||
const port = check.port;
|
||||
// Prefer bash /dev/tcp; time it with date in ms.
|
||||
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
|
||||
}
|
||||
// http
|
||||
const scheme = check.target.includes("://") ? "" : "http://";
|
||||
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
|
||||
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
|
||||
}
|
||||
|
||||
export function parseHealthResult(
|
||||
check: HealthCheck,
|
||||
output: string,
|
||||
): HealthResult {
|
||||
const line = output.trim().split("\n").pop() ?? "";
|
||||
if (check.type === "tcp") {
|
||||
const [status, ms] = line.split(/\s+/);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: status === "ok",
|
||||
latencyMs: Number(ms) || null,
|
||||
detail: status === "ok" ? "open" : "closed/timeout",
|
||||
};
|
||||
}
|
||||
const m = line.match(/^(\d{3})\s+([\d.]+)/);
|
||||
if (!m)
|
||||
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
|
||||
const code = Number(m[1]);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: code >= 200 && code < 400,
|
||||
latencyMs: Math.round(Number(m[2]) * 1000),
|
||||
detail: `HTTP ${code}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runChecks(
|
||||
client: Client,
|
||||
checks: HealthCheck[],
|
||||
): Promise<HealthResult[]> {
|
||||
return Promise.all(
|
||||
checks.map(async (check) => {
|
||||
try {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
buildHealthCheckCommand(check),
|
||||
8000,
|
||||
);
|
||||
return parseHealthResult(check, stdout);
|
||||
} catch {
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "error",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const HISTORY_KEEP = 500;
|
||||
|
||||
function recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
): Promise<void> {
|
||||
return createCurrentHostHealthRepository()
|
||||
.recordHistory(userId, hostId, results, HISTORY_KEEP)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
async function loadChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/health/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_get",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const history = (
|
||||
await createCurrentHostHealthRepository().listHistory(
|
||||
userId,
|
||||
host.id,
|
||||
200,
|
||||
)
|
||||
).map((row) => ({
|
||||
checkId: row.checkId,
|
||||
ts: row.ts,
|
||||
ok: row.ok,
|
||||
latencyMs: row.latencyMs,
|
||||
detail: row.detail,
|
||||
}));
|
||||
return { checks, results, history };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
};
|
||||
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
|
||||
throw new ManagerInputError("Invalid checks");
|
||||
}
|
||||
const interval =
|
||||
typeof intervalSeconds === "number" &&
|
||||
intervalSeconds >= 30 &&
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const now = new Date().toISOString();
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/run",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_run",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { registerServiceRoutes } from "./services.js";
|
||||
import { registerProcessRoutes } from "./processes.js";
|
||||
import { registerSimpleReadRoutes } from "./simple-reads.js";
|
||||
import { registerCronRoutes } from "./cron.js";
|
||||
import { registerPackageRoutes } from "./packages.js";
|
||||
import { registerSslRoutes } from "./ssl.js";
|
||||
import { registerFirewallRoutes } from "./firewall.js";
|
||||
import { registerUserRoutes } from "./users.js";
|
||||
import { registerHealthRoutes } from "./health.js";
|
||||
import { registerLogRoutes } from "./logs.js";
|
||||
import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
import { registerTailscaleRoutes } from "./tailscale.js";
|
||||
|
||||
/**
|
||||
* Registers every Host Metrics manager route under the `/host-metrics/managers`
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Express,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, runOnHost } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/platform/{id}:
|
||||
* get:
|
||||
* summary: Detect available management tooling on a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Platform capabilities (systemd, package manager, certbot, docker).
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
registerServiceRoutes(app, deps);
|
||||
registerProcessRoutes(app, deps);
|
||||
registerSimpleReadRoutes(app, deps);
|
||||
registerCronRoutes(app, deps);
|
||||
registerPackageRoutes(app, deps);
|
||||
registerSslRoutes(app, deps);
|
||||
registerFirewallRoutes(app, deps);
|
||||
registerUserRoutes(app, deps);
|
||||
registerHealthRoutes(app, deps);
|
||||
registerLogRoutes(app, deps);
|
||||
registerWireGuardRoutes(app, deps);
|
||||
registerTailscaleRoutes(app, deps);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
export const LOG_PATH_ALLOWLIST = ["/var/log"];
|
||||
|
||||
const COMMON_LOGS = [
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dpkg.log",
|
||||
"/var/log/nginx/access.log",
|
||||
"/var/log/nginx/error.log",
|
||||
];
|
||||
|
||||
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
|
||||
|
||||
export function clampLines(n: unknown): number {
|
||||
const v = typeof n === "string" ? Number(n) : n;
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
|
||||
return Math.min(2000, Math.max(1, Math.round(v)));
|
||||
}
|
||||
|
||||
export function buildTailCommand(path: string, lines: number): string {
|
||||
// Keep stderr intact so execElevated can detect a permission error and
|
||||
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
|
||||
// empty log with no chance to retry under sudo.
|
||||
return `tail -n ${lines} ${shellSingleQuote(path)}`;
|
||||
}
|
||||
|
||||
export function buildJournalCommand(unit: string, lines: number): string {
|
||||
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
|
||||
}
|
||||
|
||||
export function registerLogRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => `/var/log/${name}`);
|
||||
return { common: COMMON_LOGS, files: found };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"logs_tail",
|
||||
async (client, host, req) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
const unit = req.query.unit as string | undefined;
|
||||
const lines = clampLines(req.query.lines);
|
||||
|
||||
let cmd: string;
|
||||
if (unit) {
|
||||
if (!isValidSystemdUnit(unit))
|
||||
throw new ManagerInputError("Invalid unit");
|
||||
cmd = buildJournalCommand(unit, lines);
|
||||
} else if (path) {
|
||||
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
|
||||
throw new ManagerInputError("Path not allowed");
|
||||
}
|
||||
cmd = buildTailCommand(path, lines);
|
||||
} else {
|
||||
throw new ManagerInputError("Provide a path or unit");
|
||||
}
|
||||
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPackageName } from "./validation.js";
|
||||
import { detectPlatform, type PackageManager } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface UpgradablePackage {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
return "apt list --upgradable 2>/dev/null | tail -n +2";
|
||||
case "dnf":
|
||||
return "dnf -q check-update 2>/dev/null || true";
|
||||
case "yum":
|
||||
return "yum -q check-update 2>/dev/null || true";
|
||||
case "pacman":
|
||||
return "pacman -Qu 2>/dev/null || true";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpgradable(
|
||||
pkg: PackageManager,
|
||||
output: string,
|
||||
): UpgradablePackage[] {
|
||||
const out: UpgradablePackage[] = [];
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (pkg === "apt") {
|
||||
for (const line of lines) {
|
||||
// name/suite newver arch [upgradable from: oldver]
|
||||
const m = line.match(
|
||||
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
|
||||
);
|
||||
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
|
||||
}
|
||||
} else if (pkg === "dnf" || pkg === "yum") {
|
||||
for (const line of lines) {
|
||||
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
|
||||
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
|
||||
}
|
||||
} else if (pkg === "pacman") {
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
|
||||
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PackageAction = "upgrade-all" | "install" | "upgrade";
|
||||
|
||||
export function buildPackageActionCommand(
|
||||
pkg: PackageManager,
|
||||
action: PackageAction,
|
||||
name?: string,
|
||||
): string | null {
|
||||
const target = name ? ` ${name}` : "";
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
if (action === "upgrade-all")
|
||||
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
|
||||
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
|
||||
case "dnf":
|
||||
return action === "upgrade-all"
|
||||
? "dnf -y upgrade"
|
||||
: `dnf -y install${target}`;
|
||||
case "yum":
|
||||
return action === "upgrade-all"
|
||||
? "yum -y update"
|
||||
: `yum -y install${target}`;
|
||||
case "pacman":
|
||||
return action === "upgrade-all"
|
||||
? "pacman -Syu --noconfirm"
|
||||
: `pacman -S --noconfirm${target}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPackageRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
const { stdout } = await execCommand(client, cmd, 60000);
|
||||
return {
|
||||
pkg: platform.pkg,
|
||||
upgradable: parseUpgradable(platform.pkg, stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
const { action, pkg: name } = req.body as {
|
||||
action?: PackageAction;
|
||||
pkg?: string;
|
||||
};
|
||||
if (
|
||||
action !== "upgrade-all" &&
|
||||
action !== "install" &&
|
||||
action !== "upgrade"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
if (action !== "upgrade-all" && !isValidPackageName(name)) {
|
||||
throw new ManagerInputError("Invalid package name");
|
||||
}
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildPackageActionCommand(platform.pkg, action, name);
|
||||
if (!cmd) throw new ManagerInputError("No supported package manager");
|
||||
// Package operations can be slow; allow up to 10 minutes.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 600000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
|
||||
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: PackageManager;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single probe that reports which tooling is available. Each line is
|
||||
* "key=value" so the parser is trivial and order-independent.
|
||||
*/
|
||||
export const PLATFORM_PROBE_COMMAND = [
|
||||
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
|
||||
"echo docker=$(command -v docker >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
|
||||
].join("; ");
|
||||
|
||||
export function parsePlatformProbe(output: string): PlatformInfo {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
||||
}
|
||||
const on = (k: string) => map.get(k) === "1";
|
||||
|
||||
// Prefer dnf over yum when both exist (dnf is the modern front-end).
|
||||
let pkg: PackageManager = null;
|
||||
if (on("apt")) pkg = "apt";
|
||||
else if (on("dnf")) pkg = "dnf";
|
||||
else if (on("yum")) pkg = "yum";
|
||||
else if (on("pacman")) pkg = "pacman";
|
||||
|
||||
const os = map.get("os");
|
||||
return {
|
||||
hasSystemd: on("systemd"),
|
||||
pkg,
|
||||
hasCertbot: on("certbot"),
|
||||
hasAcmeSh: on("acmesh"),
|
||||
hasDocker: on("docker"),
|
||||
osPrettyName: os && os.length > 0 ? os : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
|
||||
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
|
||||
return parsePlatformProbe(stdout);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
stat: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
const LIST_PROCESSES_CMD =
|
||||
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
|
||||
|
||||
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
|
||||
export function parseProcessList(output: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(
|
||||
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
|
||||
);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
ppid: Number(m[2]),
|
||||
user: m[3],
|
||||
cpu: Number(m[4]),
|
||||
mem: Number(m[5]),
|
||||
rss: Number(m[6]),
|
||||
stat: m[7],
|
||||
command: m[8],
|
||||
args: m[9],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
return `kill -${signal} ${pid}`;
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
* get:
|
||||
* summary: List processes (rich, sortable, filterable client-side)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Process list. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}/signal:
|
||||
* post:
|
||||
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* pid: { type: integer }
|
||||
* signal: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Signal result. }
|
||||
* 400: { description: Invalid pid or signal. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
const { pid, signal } = req.body as {
|
||||
pid?: number;
|
||||
signal?: string;
|
||||
};
|
||||
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
|
||||
if (!isValidSignal(signal))
|
||||
throw new ManagerInputError("Invalid signal");
|
||||
const cmd = buildKillCommand(Number(pid), signal);
|
||||
// Try unprivileged first; elevate only if the process isn't owned.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
usedSudo: result.usedSudo,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { getErrorMessage } from "../../../utils/error-message.js";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../types/index.js";
|
||||
import { statsLogger } from "../../../utils/logger.js";
|
||||
import { ElevationError } from "./exec-elevated.js";
|
||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||
import type { HostAction } from "../../../utils/permission-manager.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
super(message);
|
||||
this.name = "AccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagerInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ManagerInputError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: HostAction,
|
||||
operation: string,
|
||||
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
||||
) {
|
||||
return async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
const result = await runOnHost(hostId, userId, level, (client, host) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagerInputError) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof AccessDeniedError) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
statsLogger.error(`Manager operation failed: ${operation}`, {
|
||||
operation,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: getErrorMessage(error, "Operation failed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const LIST_SERVICES_CMD =
|
||||
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
|
||||
|
||||
/** Parse `systemctl list-units --plain` output into structured rows. */
|
||||
export function parseServiceList(output: string): SystemdService[] {
|
||||
const services: SystemdService[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const [unit, load, active, sub, ...rest] = parts;
|
||||
if (!unit.endsWith(".service")) continue;
|
||||
services.push({
|
||||
unit,
|
||||
load,
|
||||
active,
|
||||
sub,
|
||||
description: rest.join(" "),
|
||||
});
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
export function buildServiceActionCommand(
|
||||
unit: string,
|
||||
action: ServiceAction,
|
||||
): string {
|
||||
return `systemctl ${action} ${unit}`;
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
* get:
|
||||
* summary: List systemd services
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: List of services. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}/action:
|
||||
* post:
|
||||
* summary: Start/stop/restart/enable/disable a systemd service
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* unit: { type: string }
|
||||
* action: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Action result. }
|
||||
* 400: { description: Invalid unit or action. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
const { unit, action } = req.body as {
|
||||
unit?: string;
|
||||
action?: string;
|
||||
};
|
||||
if (!isValidSystemdUnit(unit)) {
|
||||
throw new ManagerInputError("Invalid unit name");
|
||||
}
|
||||
if (!isValidServiceAction(action)) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
buildServiceActionCommand(unit, action),
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
// ─── Top by memory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemProcessRow {
|
||||
pid: number;
|
||||
user: string;
|
||||
mem: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const TOP_MEM_CMD =
|
||||
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
|
||||
|
||||
export function parseTopMemory(output: string): MemProcessRow[] {
|
||||
const rows: MemProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
user: m[2],
|
||||
mem: Number(m[3]),
|
||||
rss: Number(m[4]),
|
||||
command: m[5],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Systemd timers ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimerRow {
|
||||
next: string;
|
||||
left: string;
|
||||
last: string;
|
||||
unit: string;
|
||||
activates: string;
|
||||
}
|
||||
|
||||
const TIMERS_CMD =
|
||||
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
|
||||
|
||||
export function parseTimers(output: string): TimerRow[] {
|
||||
const rows: TimerRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("NEXT")) continue;
|
||||
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
|
||||
// the trailing UNIT + ACTIVATES which always end the line.
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
const activates = parts[parts.length - 1];
|
||||
const unit = parts[parts.length - 2];
|
||||
if (!unit.endsWith(".timer")) continue;
|
||||
rows.push({
|
||||
next: parts.slice(0, 3).join(" "),
|
||||
left: "",
|
||||
last: "",
|
||||
unit,
|
||||
activates,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
|
||||
|
||||
export interface MountUsage {
|
||||
filesystem: string;
|
||||
sizeKb: number;
|
||||
usedKb: number;
|
||||
availKb: number;
|
||||
usePct: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
|
||||
|
||||
export function parseDfMounts(output: string): MountUsage[] {
|
||||
const mounts: MountUsage[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const fs = m[1];
|
||||
// Skip pseudo/virtual filesystems that clutter the view.
|
||||
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
|
||||
mounts.push({
|
||||
filesystem: fs,
|
||||
sizeKb: Number(m[2]),
|
||||
usedKb: Number(m[3]),
|
||||
availKb: Number(m[4]),
|
||||
usePct: Number(m[5]),
|
||||
mount: m[6],
|
||||
});
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "./validation.js";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export type AcmeClient = "certbot" | "acme.sh";
|
||||
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
|
||||
|
||||
export interface CertInfo {
|
||||
client: AcmeClient | "other";
|
||||
name: string;
|
||||
domains: string[];
|
||||
expiry: string | null;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
|
||||
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
|
||||
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
|
||||
|
||||
/** Parse `certbot certificates` output. */
|
||||
export function parseCertbotCertificates(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
let current: CertInfo | null = null;
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
|
||||
if (nameM) {
|
||||
if (current) certs.push(current);
|
||||
current = {
|
||||
client: "certbot",
|
||||
name: nameM[1],
|
||||
domains: [],
|
||||
expiry: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const domM = line.match(/^Domains:\s+(.+)$/);
|
||||
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
|
||||
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
|
||||
if (expM) current.expiry = expM[1];
|
||||
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
|
||||
if (pathM) current.path = pathM[1];
|
||||
}
|
||||
if (current) certs.push(current);
|
||||
return certs;
|
||||
}
|
||||
|
||||
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
|
||||
export function parseAcmeShList(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
const lines = output.split("\n").filter((l) => l.trim());
|
||||
if (lines.length < 2) return certs;
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line
|
||||
.split(/\s{2,}|\t/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cols.length < 1) continue;
|
||||
certs.push({
|
||||
client: "acme.sh",
|
||||
name: cols[0],
|
||||
domains: [cols[0]],
|
||||
expiry: cols[cols.length - 1] || null,
|
||||
});
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
|
||||
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
|
||||
*/
|
||||
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
|
||||
export function isValidCertName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
export interface IssueRequest {
|
||||
client: AcmeClient;
|
||||
domains: string[];
|
||||
challenge: ChallengeType;
|
||||
webroot?: string;
|
||||
dnsProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the certificate issuance command for the chosen client/challenge.
|
||||
* DNS-01 provider credentials are expected to already be present in the
|
||||
* environment / provider config file; we never put secrets in argv.
|
||||
*/
|
||||
export function buildIssueCommand(req: IssueRequest): string {
|
||||
const domains = req.domains;
|
||||
if (req.client === "certbot") {
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
|
||||
}
|
||||
// acme.sh
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
|
||||
}
|
||||
|
||||
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot renew${dryRun ? " --dry-run" : ""}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
|
||||
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
|
||||
* it from management. `name` is the certbot cert name or the acme.sh domain.
|
||||
*/
|
||||
export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
|
||||
name,
|
||||
)} --delete-after-revoke`;
|
||||
}
|
||||
const d = shellSingleQuote(name);
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
const r = await execElevated(
|
||||
client,
|
||||
CERTBOT_LIST_CMD,
|
||||
host.sudoPassword,
|
||||
).catch(() => null);
|
||||
if (r) certs.push(...parseCertbotCertificates(r.stdout));
|
||||
}
|
||||
if (platform.hasAcmeSh) {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
ACMESH_LIST_CMD,
|
||||
15000,
|
||||
).catch(() => ({ stdout: "" }) as { stdout: string });
|
||||
certs.push(...parseAcmeShList(stdout));
|
||||
}
|
||||
return {
|
||||
clients: {
|
||||
certbot: platform.hasCertbot,
|
||||
acmeSh: platform.hasAcmeSh,
|
||||
},
|
||||
certs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/issue",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_issue",
|
||||
async (client, host, req) => {
|
||||
const body = req.body as Partial<IssueRequest>;
|
||||
if (body.client !== "certbot" && body.client !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!Array.isArray(body.domains) || body.domains.length === 0) {
|
||||
throw new ManagerInputError("At least one domain is required");
|
||||
}
|
||||
for (const d of body.domains) {
|
||||
if (!isValidDomain(d))
|
||||
throw new ManagerInputError(`Invalid domain: ${d}`);
|
||||
}
|
||||
const challenge = body.challenge;
|
||||
if (
|
||||
challenge !== "http-standalone" &&
|
||||
challenge !== "http-webroot" &&
|
||||
challenge !== "dns"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid challenge type");
|
||||
}
|
||||
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
|
||||
throw new ManagerInputError("Invalid DNS provider");
|
||||
}
|
||||
if (
|
||||
challenge === "http-webroot" &&
|
||||
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
|
||||
) {
|
||||
throw new ManagerInputError("Invalid or disallowed webroot path");
|
||||
}
|
||||
const cmd = buildIssueCommand(body as IssueRequest);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/renew",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_renew",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, dryRun } = req.body as {
|
||||
client?: AcmeClient;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
const cmd = buildRenewCommand(acmeClient, !!dryRun);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/ssl/{id}/revoke:
|
||||
* post:
|
||||
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* name: { type: string, description: certbot cert name or acme.sh domain }
|
||||
* responses:
|
||||
* 200: { description: Revoke result. }
|
||||
* 400: { description: Invalid client or certificate name. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/revoke",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_revoke",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, name } = req.body as {
|
||||
client?: AcmeClient;
|
||||
name?: string;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!isValidCertName(name)) {
|
||||
throw new ManagerInputError("Invalid certificate name");
|
||||
}
|
||||
const cmd = buildRevokeCommand(acmeClient, name);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user