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
@@ -0,0 +1,29 @@
|
||||
import { isReservedTunnelName, parseReservedTunnelName } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Ownership check for an action addressed by tunnel name alone.
|
||||
*
|
||||
* On-demand (forward) tunnels are deliberately never registered as configs,
|
||||
* so a check that only ran for a registered config would never run for them.
|
||||
* Host ids are small sequential integers and endpoint ids come from the
|
||||
* client, so anyone could guess `web:{hostId}:{endpointId}` and force-close
|
||||
* another user's live tunnel. The host id is read back out of the reserved
|
||||
* name instead, and a reserved name whose host id cannot be parsed fails
|
||||
* closed rather than falling through to the unchecked path.
|
||||
*/
|
||||
export async function authorizeTunnelAction(
|
||||
canAccessHost: (hostId: number) => Promise<boolean>,
|
||||
tunnelName: string,
|
||||
config: { sourceHostId?: number } | undefined,
|
||||
): Promise<{ allowed: boolean; hostId?: number }> {
|
||||
const hostId =
|
||||
config?.sourceHostId ?? parseReservedTunnelName(tunnelName)?.hostId;
|
||||
|
||||
if (hostId === undefined) {
|
||||
// Not reserved and not registered: nothing is running under this name
|
||||
// that anyone could own, so there is nothing to protect.
|
||||
return { allowed: !isReservedTunnelName(tunnelName) };
|
||||
}
|
||||
|
||||
return { allowed: await canAccessHost(hostId), hostId };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { TunnelManager } from "./manager.js";
|
||||
import { errorMessage } from "./manager.js";
|
||||
import {
|
||||
buildTunnelConfig,
|
||||
connectionToRequest,
|
||||
readTunnelConnections,
|
||||
resolveEndpoint,
|
||||
} from "./config.js";
|
||||
|
||||
export interface HostOwner {
|
||||
id: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** Every host and its owner, straight from ssh_data. */
|
||||
export async function listHostOwners(ctx: PluginContext): Promise<HostOwner[]> {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const { hosts } = await ctx.db.refs<{ hosts: any }>();
|
||||
const drizzle = await ctx.db.client<any>();
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const rows = (await drizzle
|
||||
.select({ id: hosts.id, userId: hosts.userId })
|
||||
.from(hosts)) as HostOwner[];
|
||||
return rows.filter((row) => Number.isInteger(row.id) && !!row.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts every saved tunnel marked autoStart, across every user.
|
||||
*
|
||||
* ctx.hosts only sees the acting user's hosts, so the owners come from
|
||||
* ssh_data first and each host is then resolved and connected as its owner.
|
||||
*/
|
||||
export async function startAutoStartTunnels(
|
||||
ctx: PluginContext,
|
||||
manager: TunnelManager,
|
||||
listOwners: () => Promise<HostOwner[]> = () => listHostOwners(ctx),
|
||||
): Promise<number> {
|
||||
let started = 0;
|
||||
let owners: HostOwner[];
|
||||
try {
|
||||
owners = await listOwners();
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Could not list hosts for tunnel autostart: ${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
if (!(await ctx.settings.getHost<boolean>(owner.id, "enableTunnel"))) {
|
||||
continue;
|
||||
}
|
||||
const connections = readTunnelConnections(
|
||||
await ctx.settings.getHost(owner.id, "tunnelConnections"),
|
||||
);
|
||||
if (!connections.some((connection) => connection.autoStart)) continue;
|
||||
|
||||
started += await ctx.asUser(owner.userId, async () => {
|
||||
const host = await ctx.hosts.get(owner.id);
|
||||
if (!host) return 0;
|
||||
let count = 0;
|
||||
for (const [index, connection] of connections.entries()) {
|
||||
if (!connection.autoStart) continue;
|
||||
try {
|
||||
const config = await resolveEndpoint(
|
||||
ctx,
|
||||
buildTunnelConfig(
|
||||
host,
|
||||
connectionToRequest(host, index, connection),
|
||||
owner.userId,
|
||||
),
|
||||
);
|
||||
await manager.start(config);
|
||||
count++;
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Autostart tunnel ${index + 1} on host ${owner.id} skipped: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Autostart failed for host ${owner.id}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (started > 0) ctx.log.info(`Started ${started} autostart tunnel(s)`);
|
||||
return started;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { PluginSshPromptChannel } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** Answers belong to this authenticated socket and one outstanding challenge. */
|
||||
export function createC2SPrompt(ws: WebSocket): PluginSshPromptChannel {
|
||||
return {
|
||||
ask(request) {
|
||||
if (ws.readyState !== 1)
|
||||
return Promise.reject(new Error("Tunnel closed"));
|
||||
const requestId = randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (answer: string | null, error?: Error) => {
|
||||
clearTimeout(timer);
|
||||
ws.off("message", onMessage);
|
||||
ws.off("close", onClose);
|
||||
ws.off("error", onClose);
|
||||
if (error) reject(error);
|
||||
else resolve(answer);
|
||||
};
|
||||
const onClose = () =>
|
||||
finish(null, new Error("Tunnel authentication cancelled"));
|
||||
const onMessage = (raw: Buffer, binary: boolean) => {
|
||||
if (binary) return;
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (value.type !== "auth-response" || value.requestId !== requestId)
|
||||
return;
|
||||
if (value.answer === null) {
|
||||
onClose();
|
||||
ws.close();
|
||||
} else if (
|
||||
typeof value.answer === "string" &&
|
||||
value.answer.length <= 8192
|
||||
)
|
||||
finish(value.answer);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(null, new Error("Tunnel authentication timed out"));
|
||||
ws.close();
|
||||
}, 60_000);
|
||||
ws.on("message", onMessage);
|
||||
ws.once("close", onClose);
|
||||
ws.once("error", onClose);
|
||||
ws.send(JSON.stringify({ type: "auth-prompt", requestId, request }));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Client } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { createC2SPrompt } from "./c2s-auth.js";
|
||||
|
||||
/** A desktop runtime holds one authenticated SSH connection across SOCKS streams. */
|
||||
export function createC2SConnections(ctx: PluginContext) {
|
||||
type Entry = {
|
||||
sockets: Set<WebSocket>;
|
||||
source: Promise<PluginSshConnection<Client>>;
|
||||
};
|
||||
const entries = new Map<string, Entry>();
|
||||
ctx.disposables.add(() => {
|
||||
for (const entry of entries.values())
|
||||
for (const ws of entry.sockets) ws.close();
|
||||
entries.clear();
|
||||
});
|
||||
return async (ws: WebSocket, hostId: number, sessionId?: string) => {
|
||||
if (ws.readyState !== 1) throw new Error("Tunnel closed");
|
||||
const key = JSON.stringify([
|
||||
ctx.currentActor(),
|
||||
hostId,
|
||||
typeof sessionId === "string" && sessionId.length <= 128
|
||||
? sessionId
|
||||
: randomUUID(),
|
||||
]);
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
const source = ctx.ssh.connect<Client>(hostId, {
|
||||
purpose: "tunnel",
|
||||
profile: "forward",
|
||||
timeoutMs: 60_000,
|
||||
prompt: createC2SPrompt(ws),
|
||||
});
|
||||
entry = { sockets: new Set(), source };
|
||||
entries.set(key, entry);
|
||||
const own = entry;
|
||||
void source.then(
|
||||
(connection) => {
|
||||
const close = () => {
|
||||
if (entries.get(key) === own) entries.delete(key);
|
||||
for (const socket of own.sockets) socket.close();
|
||||
};
|
||||
connection.client.once("close", close);
|
||||
connection.client.once("error", close);
|
||||
},
|
||||
() => {
|
||||
if (entries.get(key) === own) entries.delete(key);
|
||||
for (const socket of own.sockets) {
|
||||
if (socket.readyState === 1)
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Endpoint SSH authentication failed",
|
||||
}),
|
||||
);
|
||||
socket.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
const own = entry;
|
||||
own.sockets.add(ws);
|
||||
let released = false;
|
||||
const dispose = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
ws.off("close", dispose);
|
||||
ws.off("error", dispose);
|
||||
own.sockets.delete(ws);
|
||||
if (!own.sockets.size) {
|
||||
if (entries.get(key) === own) entries.delete(key);
|
||||
void own.source.then(
|
||||
(source) => source.dispose(),
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
};
|
||||
ws.once("close", dispose);
|
||||
ws.once("error", dispose);
|
||||
try {
|
||||
const source = await own.source;
|
||||
if (released || ws.readyState !== 1) throw new Error("Tunnel closed");
|
||||
return { ...source, dispose };
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createC2SConnections } from "./c2s-connections.js";
|
||||
import type { TunnelMode } from "./types.js";
|
||||
import {
|
||||
bindForwardIn,
|
||||
forwardOut,
|
||||
unbindForwardIn,
|
||||
} from "./ssh-primitives.js";
|
||||
import { getTunnelMode } from "./utils.js";
|
||||
|
||||
/** What the desktop app sends in the first message on the relay socket. */
|
||||
export interface C2SRelayTunnel {
|
||||
name?: string;
|
||||
sessionId?: string;
|
||||
mode?: TunnelMode;
|
||||
tunnelType?: "local" | "remote";
|
||||
localAddress?: string;
|
||||
remoteAddress?: string;
|
||||
bindHost?: string;
|
||||
targetHost?: string;
|
||||
sourceHostId?: number;
|
||||
sourceHostSyncId?: string;
|
||||
sourcePort?: number;
|
||||
endpointPort?: number;
|
||||
}
|
||||
|
||||
export interface C2SOpenMessage {
|
||||
type: "open" | "test";
|
||||
keepAlive?: boolean;
|
||||
tunnelConfig?: C2SRelayTunnel;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
}
|
||||
|
||||
const WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const WS_LOW_WATERMARK = 256 * 1024;
|
||||
const STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
export function sendC2SError(ws: WebSocket, message: string): void {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: "error", error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
export function describeC2SRelayError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes("administratively prohibited") ||
|
||||
lower.includes("forwarding disabled") ||
|
||||
lower.includes("open failed")
|
||||
) {
|
||||
return `SSH forwarding was rejected by the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lower.includes("address already in use") ||
|
||||
lower.includes("unable to bind") ||
|
||||
lower.includes("bind")
|
||||
) {
|
||||
return `Remote port is not available on the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lower.includes("name or service not known") ||
|
||||
lower.includes("enotfound") ||
|
||||
lower.includes("econnrefused")
|
||||
) {
|
||||
return `Tunnel target is not reachable from the endpoint host: ${message}`;
|
||||
}
|
||||
|
||||
return message || "Failed to open relay";
|
||||
}
|
||||
|
||||
function pauseForBackpressure(ws: WebSocket, source?: Duplex): void {
|
||||
if (!source || ws.bufferedAmount <= WS_HIGH_WATERMARK) return;
|
||||
|
||||
source.pause();
|
||||
const resumeTimer = setInterval(() => {
|
||||
if (
|
||||
ws.readyState !== 1 ||
|
||||
source.destroyed ||
|
||||
ws.bufferedAmount <= WS_LOW_WATERMARK
|
||||
) {
|
||||
clearInterval(resumeTimer);
|
||||
if (ws.readyState === 1 && !source.destroyed) source.resume();
|
||||
}
|
||||
}, 25);
|
||||
}
|
||||
|
||||
function sendMessage(
|
||||
ws: WebSocket,
|
||||
message: Record<string, unknown>,
|
||||
source?: Duplex,
|
||||
): void {
|
||||
if (ws.readyState !== 1) return;
|
||||
ws.send(JSON.stringify(message), (error) => {
|
||||
if (error && source && !source.destroyed) source.destroy(error);
|
||||
});
|
||||
pauseForBackpressure(ws, source);
|
||||
}
|
||||
|
||||
function writeRemoteChunk(
|
||||
target: ClientChannel,
|
||||
chunk: Buffer,
|
||||
ws: WebSocket,
|
||||
closeTarget: () => void,
|
||||
): void {
|
||||
if (!target || target.destroyed) return;
|
||||
if (target.writableLength > STREAM_WRITE_LIMIT) {
|
||||
closeTarget();
|
||||
return;
|
||||
}
|
||||
if (!target.write(chunk)) {
|
||||
ws.pause();
|
||||
target.once("drain", () => {
|
||||
if (ws.readyState === 1) ws.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a client tunnel runs through. A desktop app synced with this
|
||||
* server knows the host by its sync id, since numeric ids differ between the
|
||||
* two databases, and must never fall back to a numeric id that could point
|
||||
* at a different machine here.
|
||||
*/
|
||||
export async function resolveC2SSourceHostId(
|
||||
tunnel: Pick<C2SRelayTunnel, "sourceHostId" | "sourceHostSyncId">,
|
||||
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
|
||||
): Promise<number> {
|
||||
const syncId = tunnel.sourceHostSyncId?.trim();
|
||||
if (syncId) {
|
||||
const hostId = await findHostIdBySyncId(syncId);
|
||||
if (!hostId) {
|
||||
throw new Error("Endpoint SSH host was not found on the remote server");
|
||||
}
|
||||
return hostId;
|
||||
}
|
||||
|
||||
if (!tunnel.sourceHostId) throw new Error("Endpoint SSH host is required");
|
||||
return tunnel.sourceHostId;
|
||||
}
|
||||
|
||||
function address(value: unknown, fallback: string): string {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
interface ResolvedRelay {
|
||||
name: string;
|
||||
sessionId?: string;
|
||||
hostId: number;
|
||||
mode: TunnelMode;
|
||||
remoteAddress: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client tunnel relay: the desktop app listens locally and streams each
|
||||
* connection over this socket, and the server forwards it through one of the
|
||||
* user's hosts. Runs as the socket's user for every host lookup and connect.
|
||||
*/
|
||||
export function createC2SRelay(ctx: PluginContext) {
|
||||
let streamCounter = 0;
|
||||
const connectSource = createC2SConnections(ctx);
|
||||
|
||||
async function findHostIdBySyncId(syncId: string): Promise<number | null> {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const { hosts } = await ctx.db.refs<{ hosts: any }>();
|
||||
const drizzle = await ctx.db.client<any>();
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const rows = await drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function resolve(tunnel: C2SRelayTunnel): Promise<ResolvedRelay> {
|
||||
const hostId = await resolveC2SSourceHostId(tunnel, findHostIdBySyncId);
|
||||
const access = await ctx.hosts.checkAccess(hostId, "connect");
|
||||
if (!access.hasAccess) throw new Error("Access denied to this host");
|
||||
|
||||
return {
|
||||
name: tunnel.name || `c2s:${hostId}`,
|
||||
hostId,
|
||||
sessionId: tunnel.sessionId,
|
||||
mode: getTunnelMode({
|
||||
mode: tunnel.mode,
|
||||
tunnelType: tunnel.tunnelType,
|
||||
}),
|
||||
remoteAddress: address(
|
||||
tunnel.remoteAddress,
|
||||
address(tunnel.targetHost, "127.0.0.1"),
|
||||
),
|
||||
sourcePort: Number(tunnel.sourcePort) || 0,
|
||||
endpointPort: Number(tunnel.endpointPort) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function openRemote(ws: WebSocket, relay: ResolvedRelay) {
|
||||
const bindHost = relay.remoteAddress;
|
||||
const bindPort = relay.sourcePort;
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
|
||||
const source = await connectSource(ws, relay.hostId, relay.sessionId);
|
||||
const sourceClient = source.client;
|
||||
let actualPort: number;
|
||||
try {
|
||||
actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
||||
} catch (error) {
|
||||
source.dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (ws.readyState !== 1) {
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
|
||||
source.dispose();
|
||||
return;
|
||||
}
|
||||
const streams = new Map<string, ClientChannel>();
|
||||
let closed = false;
|
||||
|
||||
const closeStream = (streamId: string) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
streams.delete(streamId);
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const streamId of [...streams.keys()]) closeStream(streamId);
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
|
||||
source.dispose();
|
||||
};
|
||||
|
||||
sourceClient.on("tcp connection", (info, accept, reject) => {
|
||||
if (info.destPort !== actualPort) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
const inbound = accept();
|
||||
const streamId = `${Date.now()}-${++streamCounter}`;
|
||||
streams.set(streamId, inbound);
|
||||
sendMessage(ws, { type: "connection", streamId });
|
||||
|
||||
inbound.on("data", (chunk: Buffer) => {
|
||||
sendMessage(
|
||||
ws,
|
||||
{ type: "data", streamId, data: chunk.toString("base64") },
|
||||
inbound,
|
||||
);
|
||||
});
|
||||
inbound.on("close", () => {
|
||||
streams.delete(streamId);
|
||||
sendMessage(ws, { type: "close", streamId });
|
||||
});
|
||||
inbound.on("error", (error: Error) => {
|
||||
streams.delete(streamId);
|
||||
sendMessage(ws, { type: "close", streamId, error: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as {
|
||||
type?: string;
|
||||
streamId?: string;
|
||||
data?: string;
|
||||
};
|
||||
if (!message.streamId) return;
|
||||
const streamId = message.streamId;
|
||||
if (message.type === "data" && message.data) {
|
||||
const stream = streams.get(streamId);
|
||||
if (stream) {
|
||||
writeRemoteChunk(
|
||||
stream,
|
||||
Buffer.from(message.data, "base64"),
|
||||
ws,
|
||||
() => closeStream(streamId),
|
||||
);
|
||||
}
|
||||
} else if (message.type === "close") {
|
||||
closeStream(streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Invalid client tunnel relay message for ${relay.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
sourceClient.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
sourceClient.on("error", (error: Error) => {
|
||||
sendMessage(ws, { type: "error", error: error.message });
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
|
||||
ctx.log.info(
|
||||
`Client tunnel ${relay.name} bound ${bindHost}:${actualPort} on host ${relay.hostId}`,
|
||||
);
|
||||
sendMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
|
||||
}
|
||||
|
||||
async function open(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
|
||||
const relay = await resolve(message.tunnelConfig ?? {});
|
||||
if (relay.mode === "remote") {
|
||||
await openRemote(ws, relay);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHost =
|
||||
relay.mode === "dynamic" ? message.targetHost : relay.remoteAddress;
|
||||
const targetPort =
|
||||
relay.mode === "dynamic"
|
||||
? Number(message.targetPort)
|
||||
: relay.endpointPort;
|
||||
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
|
||||
throw new Error("Invalid client tunnel target");
|
||||
}
|
||||
|
||||
const source = await connectSource(ws, relay.hostId, relay.sessionId);
|
||||
let outbound: ClientChannel;
|
||||
try {
|
||||
outbound = await forwardOut(source.client, targetHost, targetPort);
|
||||
} catch (error) {
|
||||
source.dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (ws.readyState !== 1) {
|
||||
outbound.destroy();
|
||||
source.dispose();
|
||||
return;
|
||||
}
|
||||
const close = () => {
|
||||
try {
|
||||
outbound.destroy();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
source.dispose();
|
||||
};
|
||||
|
||||
outbound.on("data", (chunk: Buffer) => {
|
||||
if (ws.readyState === 1) ws.send(chunk);
|
||||
});
|
||||
outbound.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
outbound.on("error", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary) return;
|
||||
const chunk = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data)
|
||||
: Buffer.from(data as ArrayBuffer);
|
||||
outbound.write(chunk);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: "ready" }));
|
||||
}
|
||||
|
||||
async function test(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
|
||||
const relay = await resolve(message.tunnelConfig ?? {});
|
||||
const source = await connectSource(ws, relay.hostId, relay.sessionId);
|
||||
try {
|
||||
if (relay.mode === "remote") {
|
||||
const bindPort = relay.sourcePort;
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
const actualPort = await bindForwardIn(
|
||||
source.client,
|
||||
relay.remoteAddress,
|
||||
bindPort,
|
||||
);
|
||||
unbindForwardIn(source.client, relay.remoteAddress, actualPort);
|
||||
} else if (relay.mode === "local") {
|
||||
if (!Number.isInteger(relay.endpointPort) || relay.endpointPort < 1) {
|
||||
throw new Error("Invalid remote target port");
|
||||
}
|
||||
const outbound = await forwardOut(
|
||||
source.client,
|
||||
relay.remoteAddress,
|
||||
relay.endpointPort,
|
||||
);
|
||||
outbound.destroy();
|
||||
}
|
||||
sendMessage(ws, { type: "ready" });
|
||||
} finally {
|
||||
if (!message.keepAlive) source.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** The ctx.ws.route handler. Core has already authenticated the socket. */
|
||||
return function handleConnection(ws: WebSocket, userId: string): void {
|
||||
let opened = false;
|
||||
|
||||
ws.on("error", (error: Error) => {
|
||||
ctx.log.warn(`Client tunnel relay socket error: ${error.message}`);
|
||||
});
|
||||
|
||||
ws.once("message", (raw) => {
|
||||
void ctx
|
||||
.asUser(userId, async () => {
|
||||
if (!(await ctx.rbac.has("use"))) {
|
||||
throw new Error("Access denied");
|
||||
}
|
||||
const message = JSON.parse(raw.toString()) as C2SOpenMessage;
|
||||
if (message.type !== "open" && message.type !== "test") {
|
||||
throw new Error("Invalid client tunnel relay request");
|
||||
}
|
||||
opened = true;
|
||||
if (message.type === "test") await test(ws, message);
|
||||
else await open(ws, message);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = describeC2SRelayError(error);
|
||||
ctx.log.warn(`Failed to open client tunnel relay: ${message}`);
|
||||
sendC2SError(ws, message);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (!opened) ctx.log.debug("Client tunnel relay closed before opening");
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginHostSummary,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type {
|
||||
TunnelConfig,
|
||||
TunnelConnectRequest,
|
||||
TunnelConnection,
|
||||
} from "./types.js";
|
||||
import { serverTunnelName } from "../shared/tunnel-naming.js";
|
||||
import {
|
||||
findHostByTunnelEndpoint,
|
||||
getTunnelMode,
|
||||
isSingleHostTunnel,
|
||||
parseTunnelName,
|
||||
} from "./utils.js";
|
||||
|
||||
export function hostLabel(host: {
|
||||
name?: string | null;
|
||||
username: string;
|
||||
ip: string;
|
||||
}): string {
|
||||
return host.name || `${host.username}@${host.ip}`;
|
||||
}
|
||||
|
||||
/** The name a host's saved tunnel runs under. */
|
||||
export function savedTunnelName(
|
||||
host: PluginHostSummary,
|
||||
index: number,
|
||||
connection: Pick<
|
||||
TunnelConnection,
|
||||
"sourcePort" | "endpointHost" | "endpointPort"
|
||||
>,
|
||||
): string {
|
||||
return serverTunnelName(host, index, connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the runtime config for a tunnel from what the client or a host's
|
||||
* saved list says about it. Credentials are never part of it: both legs
|
||||
* resolve theirs through ctx.ssh as `userId`.
|
||||
*/
|
||||
export function buildTunnelConfig(
|
||||
host: PluginHostSummary,
|
||||
request: Omit<TunnelConnectRequest, "sourceHostId">,
|
||||
userId: string,
|
||||
): TunnelConfig {
|
||||
const mode = getTunnelMode(request);
|
||||
return {
|
||||
name: request.name,
|
||||
scope: request.scope || "s2s",
|
||||
mode,
|
||||
tunnelType: request.tunnelType || (mode === "remote" ? "remote" : "local"),
|
||||
bindHost: request.bindHost,
|
||||
targetHost: request.targetHost,
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: request.tunnelIndex,
|
||||
requestingUserId: userId,
|
||||
hostName: hostLabel(host),
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
endpointHost: (request.endpointHost ?? "").trim(),
|
||||
sourcePort: Number(request.sourcePort),
|
||||
endpointPort: Number(request.endpointPort),
|
||||
maxRetries: Number(request.maxRetries) || 3,
|
||||
retryInterval: (Number(request.retryInterval) || 5) * 1000,
|
||||
autoStart: Boolean(request.autoStart),
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionToRequest(
|
||||
host: PluginHostSummary,
|
||||
index: number,
|
||||
connection: TunnelConnection,
|
||||
): Omit<TunnelConnectRequest, "sourceHostId"> {
|
||||
return {
|
||||
name: savedTunnelName(host, index, connection),
|
||||
tunnelIndex: index,
|
||||
scope: connection.scope || "s2s",
|
||||
mode: connection.mode,
|
||||
tunnelType: connection.tunnelType,
|
||||
bindHost: connection.bindHost,
|
||||
targetHost: connection.targetHost,
|
||||
endpointHost: connection.endpointHost,
|
||||
sourcePort: connection.sourcePort,
|
||||
endpointPort: connection.endpointPort,
|
||||
maxRetries: connection.maxRetries,
|
||||
retryInterval: connection.retryInterval,
|
||||
autoStart: connection.autoStart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in the endpoint leg from the user's own host list. A local or
|
||||
* dynamic tunnel whose endpoint is not a saved host just forwards to that
|
||||
* address through the source; a remote tunnel needs a real SSH host there.
|
||||
* Must run as the tunnel's user.
|
||||
*/
|
||||
export async function resolveEndpoint(
|
||||
ctx: PluginContext,
|
||||
config: TunnelConfig,
|
||||
): Promise<TunnelConfig> {
|
||||
if (isSingleHostTunnel(config)) return config;
|
||||
|
||||
const hosts = await ctx.hosts.list();
|
||||
const endpoint = findHostByTunnelEndpoint(hosts, config.endpointHost);
|
||||
|
||||
if (!endpoint) {
|
||||
if (getTunnelMode(config) === "remote") {
|
||||
throw new Error(
|
||||
`Endpoint host '${config.endpointHost}' not found in database`,
|
||||
);
|
||||
}
|
||||
return { ...config, endpointIP: config.endpointIP || config.endpointHost };
|
||||
}
|
||||
|
||||
const access = await ctx.hosts.checkAccess(endpoint.id, "connect");
|
||||
if (!access.hasAccess) throw new Error("Endpoint host not found");
|
||||
|
||||
return {
|
||||
...config,
|
||||
endpointHostId: endpoint.id,
|
||||
endpointIP: endpoint.ip,
|
||||
endpointSSHPort: endpoint.port,
|
||||
endpointUsername: endpoint.username,
|
||||
};
|
||||
}
|
||||
|
||||
/** A host's saved tunnel list, as its host setting holds it. */
|
||||
export function readTunnelConnections(value: unknown): TunnelConnection[] {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved tunnel a name points at, for starting one by name alone
|
||||
* (automations). Must run as the user it is for.
|
||||
*/
|
||||
export async function findSavedTunnel(
|
||||
ctx: PluginContext,
|
||||
name: string,
|
||||
): Promise<{
|
||||
host: PluginHostSummary;
|
||||
index: number;
|
||||
connection: TunnelConnection;
|
||||
} | null> {
|
||||
const parsed = parseTunnelName(name);
|
||||
if (parsed.isLegacyFormat || parsed.hostId === undefined) return null;
|
||||
const index = parsed.tunnelIndex ?? -1;
|
||||
|
||||
const host = await ctx.hosts.get(parsed.hostId);
|
||||
if (!host) return null;
|
||||
if (!(await ctx.settings.getHost<boolean>(host.id, "enableTunnel"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connections = readTunnelConnections(
|
||||
await ctx.settings.getHost(host.id, "tunnelConnections"),
|
||||
);
|
||||
const connection = connections[index];
|
||||
if (!connection) return null;
|
||||
if (savedTunnelName(host, index, connection) !== name) return null;
|
||||
return { host, index, connection };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Router } from "express";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { presets } from "./tables.js";
|
||||
import { createPresetRepository } from "./repository.js";
|
||||
import { createTunnelManager } from "./manager.js";
|
||||
import { registerTunnelRoutes } from "./routes.js";
|
||||
import { createC2SRelay } from "./c2s-relay.js";
|
||||
import { createTunnelsService } from "./service.js";
|
||||
import { startAutoStartTunnels } from "./autostart.js";
|
||||
|
||||
export type {
|
||||
TunnelsAccess,
|
||||
TunnelForwardHandle,
|
||||
TunnelForwardTarget,
|
||||
} from "./service.js";
|
||||
|
||||
// Long enough for core's host settings migration, which runs right after
|
||||
// plugins activate, to have copied the saved tunnels across on an upgrade.
|
||||
const AUTOSTART_DELAY_MS = 3000;
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(presets);
|
||||
const repository = createPresetRepository(ctx.db, table);
|
||||
|
||||
const manager = createTunnelManager(ctx);
|
||||
ctx.disposables.add(() => manager.dispose());
|
||||
|
||||
registerTunnelRoutes(ctx.http.router<Router>(), ctx, manager, repository);
|
||||
|
||||
const relay = createC2SRelay(ctx);
|
||||
ctx.ws.route("/c2s/stream", (connection) =>
|
||||
relay(connection.socket as WebSocket, connection.userId),
|
||||
);
|
||||
|
||||
ctx.services.provide("tunnels.access", createTunnelsService(ctx, manager));
|
||||
|
||||
// Deferred so activation is not held up by SSH handshakes.
|
||||
const autostart = setTimeout(() => {
|
||||
void startAutoStartTunnels(ctx, manager);
|
||||
}, AUTOSTART_DELAY_MS);
|
||||
autostart.unref?.();
|
||||
ctx.disposables.add(() => clearTimeout(autostart));
|
||||
|
||||
ctx.log.info("Tunnel routes mounted at /plugin-api/tunnels");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
import { createServer, Socket, type Server } from "node:net";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
CONNECTION_STATES,
|
||||
type TunnelConfig,
|
||||
type TunnelStatus,
|
||||
} from "./types.js";
|
||||
import {
|
||||
bindForwardIn,
|
||||
forwardOut,
|
||||
pipeTunnelStreams,
|
||||
unbindForwardIn,
|
||||
} from "./ssh-primitives.js";
|
||||
import { handleSocks5Connect } from "./socks5-relay.js";
|
||||
import {
|
||||
classifyTunnelError,
|
||||
getTunnelBindHost,
|
||||
getTunnelMode,
|
||||
getTunnelScope,
|
||||
isReservedTunnelName,
|
||||
resolveS2SLocalTargetHost,
|
||||
shouldEstablishDirectTunnel,
|
||||
} from "./utils.js";
|
||||
|
||||
export interface TunnelRuntime {
|
||||
sourceClient: Client;
|
||||
endpointClient?: Client;
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** A status snapshot as a user sees it, keyed by tunnel name. */
|
||||
export type TunnelStatusMap = Record<string, TunnelStatus>;
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 60_000;
|
||||
const MANUAL_DISCONNECT_HOLD_MS = 5_000;
|
||||
const ACCESS_CACHE_MS = 60_000;
|
||||
|
||||
export function errorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export type TunnelManager = ReturnType<typeof createTunnelManager>;
|
||||
|
||||
/**
|
||||
* Every running tunnel and its retry state, created fresh in activate so a
|
||||
* disable-then-enable starts clean.
|
||||
*
|
||||
* Both SSH legs go through ctx.ssh.connect, so tunnels get host key checks,
|
||||
* auth providers, proxies and jump hosts the same way every other transport
|
||||
* does. The endpoint leg of a source-to-endpoint tunnel is opened over a
|
||||
* forwardOut channel on the source connection, handed to ctx.ssh as `sock`.
|
||||
*/
|
||||
export function createTunnelManager(ctx: PluginContext) {
|
||||
const log = ctx.log;
|
||||
|
||||
const configs = new Map<string, TunnelConfig>();
|
||||
const statuses = new Map<string, TunnelStatus>();
|
||||
const runtimes = new Map<string, TunnelRuntime>();
|
||||
const retryCounters = new Map<string, number>();
|
||||
const retryTimers = new Map<string, NodeJS.Timeout>();
|
||||
const countdownTimers = new Map<string, NodeJS.Timeout>();
|
||||
const manualDisconnects = new Set<string>();
|
||||
const manualHoldTimers = new Set<NodeJS.Timeout>();
|
||||
const retryExhausted = new Set<string>();
|
||||
const cleanupInProgress = new Set<string>();
|
||||
const connecting = new Set<string>();
|
||||
const lastErrors = new Map<string, string>();
|
||||
const lastErrorTypes = new Map<string, TunnelStatus["errorType"]>();
|
||||
const pendingOperations = new Map<string, Promise<void>>();
|
||||
const listeners = new Set<() => void>();
|
||||
const accessCache = new Map<string, { allowed: boolean; at: number }>();
|
||||
let disposed = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
log.warn(`Tunnel status listener failed: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(name: string, status: TunnelStatus): void {
|
||||
if (
|
||||
status.status === CONNECTION_STATES.CONNECTED &&
|
||||
retryTimers.has(name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...status };
|
||||
|
||||
if (retryExhausted.has(name) && next.status === CONNECTION_STATES.FAILED) {
|
||||
const previous = lastErrors.get(name);
|
||||
next.reason = previous
|
||||
? `Max retries exhausted: ${previous}`
|
||||
: "Max retries exhausted";
|
||||
}
|
||||
|
||||
if (next.status === CONNECTION_STATES.FAILED && next.reason) {
|
||||
lastErrors.set(name, next.reason);
|
||||
if (next.errorType) lastErrorTypes.set(name, next.errorType);
|
||||
} else if (
|
||||
(next.status === CONNECTION_STATES.CONNECTING ||
|
||||
next.status === CONNECTION_STATES.RETRYING ||
|
||||
next.status === CONNECTION_STATES.WAITING) &&
|
||||
!next.reason
|
||||
) {
|
||||
next.reason = lastErrors.get(name);
|
||||
next.errorType = lastErrorTypes.get(name);
|
||||
} else if (
|
||||
next.status === CONNECTION_STATES.CONNECTED ||
|
||||
(next.status === CONNECTION_STATES.DISCONNECTED && next.manualDisconnect)
|
||||
) {
|
||||
lastErrors.delete(name);
|
||||
lastErrorTypes.delete(name);
|
||||
}
|
||||
|
||||
statuses.set(name, next);
|
||||
ctx.events.emit("plugin.tunnels.status", {
|
||||
name,
|
||||
sourceHostId: configs.get(name)?.sourceHostId,
|
||||
status: next,
|
||||
});
|
||||
notify();
|
||||
}
|
||||
|
||||
function clearRetryTimers(name: string): void {
|
||||
const retry = retryTimers.get(name);
|
||||
if (retry) {
|
||||
clearTimeout(retry);
|
||||
retryTimers.delete(name);
|
||||
}
|
||||
const countdown = countdownTimers.get(name);
|
||||
if (countdown) {
|
||||
clearInterval(countdown);
|
||||
countdownTimers.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function resetRetryState(name: string): void {
|
||||
retryCounters.delete(name);
|
||||
retryExhausted.delete(name);
|
||||
lastErrors.delete(name);
|
||||
lastErrorTypes.delete(name);
|
||||
cleanupInProgress.delete(name);
|
||||
connecting.delete(name);
|
||||
clearRetryTimers(name);
|
||||
}
|
||||
|
||||
async function cleanup(name: string, force = false): Promise<void> {
|
||||
if (cleanupInProgress.has(name)) return;
|
||||
if (!force && connecting.has(name)) return;
|
||||
|
||||
cleanupInProgress.add(name);
|
||||
const runtime = runtimes.get(name);
|
||||
if (runtime) {
|
||||
runtimes.delete(name);
|
||||
try {
|
||||
runtime.close();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error while closing tunnel ${name}`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
cleanupInProgress.delete(name);
|
||||
clearRetryTimers(name);
|
||||
}
|
||||
|
||||
/** One SSH leg, as the tunnel's owner, through core's connect pipeline. */
|
||||
async function openLeg(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
sock?: ClientChannel,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return ctx.asUser(userId, () =>
|
||||
ctx.ssh.connect<Client>(hostId, {
|
||||
purpose: "tunnel",
|
||||
profile: "forward",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
sock,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function directTargetHost(config: TunnelConfig): string {
|
||||
const target = config.targetHost?.trim();
|
||||
if (target) return target;
|
||||
if (
|
||||
config.endpointHostId !== undefined &&
|
||||
config.endpointHostId === config.sourceHostId
|
||||
) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
return config.endpointHost || "127.0.0.1";
|
||||
}
|
||||
|
||||
async function establishDirect(
|
||||
source: PluginSshConnection<Client>,
|
||||
config: TunnelConfig,
|
||||
): Promise<TunnelRuntime> {
|
||||
const sourceClient = source.client;
|
||||
const name = config.name;
|
||||
const mode = getTunnelMode(config);
|
||||
const bindHost = getTunnelBindHost(config);
|
||||
const targetHost = directTargetHost(config);
|
||||
const targetPort = config.endpointPort;
|
||||
|
||||
if (mode === "remote") {
|
||||
const remoteBindPort = await bindForwardIn(
|
||||
sourceClient,
|
||||
targetHost,
|
||||
config.sourcePort,
|
||||
);
|
||||
const sockets = new Set<Socket>();
|
||||
const onTcp = (
|
||||
info: { destPort: number },
|
||||
accept: () => ClientChannel,
|
||||
reject: () => void,
|
||||
) => {
|
||||
if (info.destPort !== remoteBindPort) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
const inbound = accept();
|
||||
const local = new Socket();
|
||||
sockets.add(local);
|
||||
local.connect(targetPort, bindHost, () => {
|
||||
pipeTunnelStreams(inbound, Promise.resolve(local), name, log);
|
||||
});
|
||||
local.on("error", () => {
|
||||
inbound.destroy();
|
||||
sockets.delete(local);
|
||||
});
|
||||
local.on("close", () => sockets.delete(local));
|
||||
};
|
||||
sourceClient.on("tcp connection", onTcp);
|
||||
|
||||
return {
|
||||
sourceClient,
|
||||
bindHost: targetHost,
|
||||
bindPort: remoteBindPort,
|
||||
close: () => {
|
||||
sourceClient.off("tcp connection", onTcp);
|
||||
unbindForwardIn(sourceClient, targetHost, remoteBindPort, log);
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sockets.clear();
|
||||
source.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Local and dynamic: listen here, forward through the source.
|
||||
const sockets = new Set<Socket>();
|
||||
const server: Server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {
|
||||
sockets.delete(socket);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
if (mode === "dynamic") {
|
||||
handleSocks5Connect(
|
||||
socket,
|
||||
(host, port) => forwardOut(sourceClient, host, port),
|
||||
name,
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
forwardOut(sourceClient, targetHost, targetPort)
|
||||
.then((outbound) =>
|
||||
pipeTunnelStreams(socket, Promise.resolve(outbound), name, log),
|
||||
)
|
||||
.catch(() => socket.destroy());
|
||||
});
|
||||
|
||||
const boundPort = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen({ host: bindHost, port: config.sourcePort }, () => {
|
||||
server.removeListener("error", reject);
|
||||
// With port 0 the kernel picks, so report what was actually bound.
|
||||
resolve((server.address() as { port: number }).port);
|
||||
});
|
||||
});
|
||||
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
const close = () => {
|
||||
if (idleTimer) clearInterval(idleTimer);
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sockets.clear();
|
||||
server.close();
|
||||
source.dispose();
|
||||
};
|
||||
|
||||
// On-demand tunnels close themselves once nothing has used them for a
|
||||
// while. The socket set only exists in here, so the timer does too.
|
||||
if (config.idleTimeoutMs && config.idleTimeoutMs > 0) {
|
||||
const idleTimeoutMs = config.idleTimeoutMs;
|
||||
let idleSince: number | null = Date.now();
|
||||
idleTimer = setInterval(
|
||||
() => {
|
||||
if (sockets.size > 0) {
|
||||
idleSince = null;
|
||||
return;
|
||||
}
|
||||
if (idleSince === null) {
|
||||
idleSince = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - idleSince < idleTimeoutMs) return;
|
||||
// Acts by name, so skip it if a reopen already replaced this one.
|
||||
if (runtimes.get(name)?.sourceClient !== sourceClient) return;
|
||||
log.info(`Closing idle tunnel ${name}`);
|
||||
void cleanup(name, true);
|
||||
},
|
||||
Math.min(30_000, idleTimeoutMs),
|
||||
);
|
||||
idleTimer.unref();
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Tunnel ${name} listening on ${bindHost}:${boundPort} (${mode}) to ${targetHost}:${targetPort}`,
|
||||
);
|
||||
|
||||
return { sourceClient, bindHost, bindPort: boundPort, close };
|
||||
}
|
||||
|
||||
async function establishManaged(
|
||||
source: PluginSshConnection<Client>,
|
||||
config: TunnelConfig,
|
||||
): Promise<TunnelRuntime> {
|
||||
const sourceClient = source.client;
|
||||
const name = config.name;
|
||||
if (config.endpointHostId === undefined || !config.endpointIP) {
|
||||
throw new Error("Endpoint host not found");
|
||||
}
|
||||
|
||||
const channel = await forwardOut(
|
||||
sourceClient,
|
||||
config.endpointIP,
|
||||
config.endpointSSHPort ?? 22,
|
||||
);
|
||||
let endpoint: PluginSshConnection<Client>;
|
||||
try {
|
||||
endpoint = await openLeg(
|
||||
config.requestingUserId,
|
||||
config.endpointHostId,
|
||||
channel,
|
||||
);
|
||||
} catch (error) {
|
||||
channel.destroy();
|
||||
throw error;
|
||||
}
|
||||
const endpointClient = endpoint.client;
|
||||
|
||||
const mode = getTunnelMode(config);
|
||||
const bindHost = getTunnelBindHost(config);
|
||||
const bindClient = mode === "remote" ? endpointClient : sourceClient;
|
||||
const outboundClient = mode === "remote" ? sourceClient : endpointClient;
|
||||
const bindPort =
|
||||
mode === "remote" ? config.endpointPort : config.sourcePort;
|
||||
const targetHost =
|
||||
mode === "remote"
|
||||
? config.targetHost || "127.0.0.1"
|
||||
: resolveS2SLocalTargetHost(config);
|
||||
const targetPort =
|
||||
mode === "remote" ? config.sourcePort : config.endpointPort;
|
||||
|
||||
let actualPort: number;
|
||||
try {
|
||||
actualPort = await bindForwardIn(bindClient, bindHost, bindPort);
|
||||
} catch (error) {
|
||||
endpoint.dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const onTcp = (
|
||||
info: { destPort: number },
|
||||
accept: () => ClientChannel,
|
||||
reject: () => void,
|
||||
) => {
|
||||
if (info.destPort !== actualPort) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
const inbound = accept();
|
||||
if (mode === "dynamic") {
|
||||
handleSocks5Connect(
|
||||
inbound,
|
||||
(host, port) => forwardOut(outboundClient, host, port),
|
||||
name,
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
pipeTunnelStreams(
|
||||
inbound,
|
||||
forwardOut(outboundClient, targetHost, targetPort),
|
||||
name,
|
||||
log,
|
||||
);
|
||||
};
|
||||
bindClient.on("tcp connection", onTcp);
|
||||
|
||||
// Losing the endpoint leg makes the tunnel useless, so let the source
|
||||
// close too and the usual disconnect handling take it from there.
|
||||
endpointClient.once("close", () => {
|
||||
if (runtimes.get(name)?.endpointClient === endpointClient) {
|
||||
source.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
log.info(
|
||||
`Tunnel ${name} bound ${bindHost}:${actualPort} (${mode}) to ${targetHost}:${targetPort}`,
|
||||
);
|
||||
|
||||
return {
|
||||
sourceClient,
|
||||
endpointClient,
|
||||
bindHost,
|
||||
bindPort: actualPort,
|
||||
close: () => {
|
||||
bindClient.off("tcp connection", onTcp);
|
||||
unbindForwardIn(bindClient, bindHost, actualPort, log);
|
||||
endpoint.dispose();
|
||||
source.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function establish(config: TunnelConfig): Promise<TunnelRuntime> {
|
||||
const source = await openLeg(config.requestingUserId, config.sourceHostId);
|
||||
try {
|
||||
return shouldEstablishDirectTunnel(config)
|
||||
? await establishDirect(source, config)
|
||||
: await establishManaged(source, config);
|
||||
} catch (error) {
|
||||
source.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reacts to the source connection dropping after the tunnel was up. */
|
||||
function watch(runtime: TunnelRuntime, config: TunnelConfig): void {
|
||||
const name = config.name;
|
||||
const client = runtime.sourceClient;
|
||||
client.on("error", (error: Error) => {
|
||||
log.warn(`Tunnel ${name} connection error: ${error.message}`);
|
||||
});
|
||||
client.once("close", () => {
|
||||
if (runtimes.get(name)?.sourceClient !== client) return;
|
||||
if (retryTimers.has(name)) return;
|
||||
if (!manualDisconnects.has(name)) {
|
||||
const current = statuses.get(name);
|
||||
if (current?.status !== CONNECTION_STATES.FAILED) {
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
});
|
||||
}
|
||||
}
|
||||
void handleDisconnect(name, config, !manualDisconnects.has(name), client);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisconnect(
|
||||
name: string,
|
||||
config: TunnelConfig | null,
|
||||
shouldRetry = true,
|
||||
closingClient?: Client,
|
||||
): Promise<void> {
|
||||
if (isReservedTunnelName(name)) {
|
||||
// On-demand tunnels are reopened by whoever needs them, never retried
|
||||
// here. A deferred close for a client that a reopen already replaced
|
||||
// must not tear down the replacement.
|
||||
const current = runtimes.get(name);
|
||||
if (closingClient && current && current.sourceClient !== closingClient) {
|
||||
return;
|
||||
}
|
||||
await cleanup(name, true);
|
||||
return;
|
||||
}
|
||||
|
||||
while (cleanupInProgress.has(name)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await cleanup(name);
|
||||
|
||||
if (manualDisconnects.has(name)) {
|
||||
resetRetryState(name);
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Past the manual branch, so this is a drop nobody asked for.
|
||||
if (config?.requestingUserId) {
|
||||
ctx.events.emit("plugin.tunnels.tunnel_disconnected", {
|
||||
userId: config.requestingUserId,
|
||||
hostId: config.sourceHostId,
|
||||
tunnelName: name,
|
||||
});
|
||||
}
|
||||
|
||||
if (retryExhausted.has(name)) {
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "Max retries already exhausted",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryTimers.has(name) || disposed) return;
|
||||
|
||||
if (!shouldRetry || !config) {
|
||||
// Keep the reason the failure was just reported with.
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: lastErrors.get(name),
|
||||
errorType: lastErrorTypes.get(name),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const maxRetries = config.maxRetries || 3;
|
||||
const retryInterval = config.retryInterval || 5000;
|
||||
const retryCount = (retryCounters.get(name) || 0) + 1;
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
log.error(`All ${maxRetries} retries failed for ${name}`);
|
||||
retryExhausted.add(name);
|
||||
retryCounters.delete(name);
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
retryExhausted: true,
|
||||
reason: "Max retries exhausted",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
retryCounters.set(name, retryCount);
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.RETRYING,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
nextRetryIn: retryInterval / 1000,
|
||||
});
|
||||
|
||||
let nextRetryIn = Math.ceil(retryInterval / 1000);
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.WAITING,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
nextRetryIn,
|
||||
});
|
||||
|
||||
countdownTimers.set(
|
||||
name,
|
||||
setInterval(() => {
|
||||
nextRetryIn--;
|
||||
if (nextRetryIn > 0) {
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.WAITING,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
nextRetryIn,
|
||||
});
|
||||
}
|
||||
}, 1000),
|
||||
);
|
||||
|
||||
retryTimers.set(
|
||||
name,
|
||||
setTimeout(() => {
|
||||
clearRetryTimers(name);
|
||||
if (!manualDisconnects.has(name)) {
|
||||
void connect(config, retryCount);
|
||||
}
|
||||
}, retryInterval),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a tunnel. Failures are reported over the status broadcast and
|
||||
* retried there, so by default this never rejects. `throwOnError` is for a
|
||||
* caller that waits on the result (forward()), which gets the runtime back.
|
||||
*/
|
||||
async function connect(
|
||||
config: TunnelConfig,
|
||||
retryAttempt = 0,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<TunnelRuntime | null> {
|
||||
const name = config.name;
|
||||
if (manualDisconnects.has(name) || disposed) return null;
|
||||
|
||||
connecting.add(name);
|
||||
await cleanup(name, true);
|
||||
|
||||
if (retryAttempt === 0) {
|
||||
retryExhausted.delete(name);
|
||||
retryCounters.delete(name);
|
||||
}
|
||||
|
||||
const current = statuses.get(name);
|
||||
if (!current || current.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (getTunnelScope(config) !== "s2s") {
|
||||
throw new Error(
|
||||
"Client tunnels must be started from the desktop app's local configuration",
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = await establish(config);
|
||||
connecting.delete(name);
|
||||
|
||||
if (manualDisconnects.has(name) || disposed) {
|
||||
runtime.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
runtimes.set(name, runtime);
|
||||
watch(runtime, config);
|
||||
broadcast(name, { connected: true, status: CONNECTION_STATES.CONNECTED });
|
||||
return runtime;
|
||||
} catch (error) {
|
||||
connecting.delete(name);
|
||||
const message = errorMessage(error, "Failed to create tunnel");
|
||||
const errorType = classifyTunnelError(message);
|
||||
log.error(
|
||||
`Tunnel ${name} failed to connect: ${message}`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
|
||||
if (!manualDisconnects.has(name)) {
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
errorType,
|
||||
reason: message,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldNotRetry =
|
||||
errorType === "AUTHENTICATION_FAILED" ||
|
||||
errorType === "CONNECTION_FAILED" ||
|
||||
manualDisconnects.has(name) ||
|
||||
options.throwOnError === true;
|
||||
await handleDisconnect(name, config, !shouldNotRetry);
|
||||
|
||||
if (options.throwOnError) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops a tunnel on request. Holds off retries for a few seconds. */
|
||||
async function stop(name: string): Promise<void> {
|
||||
const config = configs.get(name) ?? null;
|
||||
manualDisconnects.add(name);
|
||||
retryCounters.delete(name);
|
||||
retryExhausted.delete(name);
|
||||
clearRetryTimers(name);
|
||||
|
||||
await cleanup(name, true);
|
||||
broadcast(name, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
await handleDisconnect(name, config, false);
|
||||
|
||||
const hold = setTimeout(() => {
|
||||
manualHoldTimers.delete(hold);
|
||||
manualDisconnects.delete(name);
|
||||
}, MANUAL_DISCONNECT_HOLD_MS);
|
||||
manualHoldTimers.add(hold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a configured tunnel by name, waiting for any operation already in
|
||||
* flight for it first. The start itself is not awaited: its outcome comes
|
||||
* back over the status stream.
|
||||
*/
|
||||
async function start(config: TunnelConfig): Promise<void> {
|
||||
const name = config.name;
|
||||
const pending = pendingOperations.get(name);
|
||||
if (pending) {
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
|
||||
const existing = configs.get(name);
|
||||
if (
|
||||
existing &&
|
||||
(existing.sourceHostId !== config.sourceHostId ||
|
||||
existing.tunnelIndex !== config.tunnelIndex)
|
||||
) {
|
||||
throw new Error(`Tunnel name collision detected: ${name}`);
|
||||
}
|
||||
|
||||
manualDisconnects.delete(name);
|
||||
retryCounters.delete(name);
|
||||
retryExhausted.delete(name);
|
||||
configs.set(name, config);
|
||||
|
||||
const operation = connect(config, 0).then(() => undefined);
|
||||
pendingOperations.set(name, operation);
|
||||
void operation.finally(() => {
|
||||
if (pendingOperations.get(name) === operation) {
|
||||
pendingOperations.delete(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `userId` can reach `hostId`, cached briefly so broadcasts stay cheap. */
|
||||
async function canAccessHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
const cacheKey = `${userId}:${hostId}`;
|
||||
const cached = accessCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < ACCESS_CACHE_MS) {
|
||||
return cached.allowed;
|
||||
}
|
||||
let allowed = false;
|
||||
try {
|
||||
const access =
|
||||
ctx.currentActor() === userId
|
||||
? await ctx.hosts.checkAccess(hostId, "connect")
|
||||
: await ctx.asUser(userId, () =>
|
||||
ctx.hosts.checkAccess(hostId, "connect"),
|
||||
);
|
||||
allowed = access.hasAccess;
|
||||
} catch {
|
||||
allowed = false;
|
||||
}
|
||||
accessCache.set(cacheKey, { allowed, at: Date.now() });
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of every tunnel whose source host the user can reach. Names carry
|
||||
* host labels and endpoints and errors can name internal hosts, so nothing
|
||||
* else is shown. On-demand tunnels are never listed.
|
||||
*/
|
||||
async function statusesFor(userId: string): Promise<TunnelStatusMap> {
|
||||
const result: TunnelStatusMap = {};
|
||||
for (const [name, status] of statuses) {
|
||||
if (isReservedTunnelName(name)) continue;
|
||||
const hostId = configs.get(name)?.sourceHostId;
|
||||
if (hostId === undefined) continue;
|
||||
if (await canAccessHost(userId, hostId)) result[name] = status;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function canAccessTunnel(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const hostId = configs.get(name)?.sourceHostId;
|
||||
if (hostId === undefined) return false;
|
||||
return canAccessHost(userId, hostId);
|
||||
}
|
||||
|
||||
function onChange(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
disposed = true;
|
||||
for (const name of [...runtimes.keys()]) {
|
||||
const runtime = runtimes.get(name);
|
||||
runtimes.delete(name);
|
||||
try {
|
||||
runtime?.close();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
for (const name of [...retryTimers.keys(), ...countdownTimers.keys()]) {
|
||||
clearRetryTimers(name);
|
||||
}
|
||||
for (const hold of manualHoldTimers) clearTimeout(hold);
|
||||
manualHoldTimers.clear();
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
configs,
|
||||
statuses,
|
||||
runtimes,
|
||||
manualDisconnects,
|
||||
connecting,
|
||||
broadcast,
|
||||
cleanup,
|
||||
connect,
|
||||
start,
|
||||
stop,
|
||||
handleDisconnect,
|
||||
statusesFor,
|
||||
canAccessHost,
|
||||
canAccessTunnel,
|
||||
onChange,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface PresetRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
config: string;
|
||||
platform: string | null;
|
||||
computerName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PresetCreateInput {
|
||||
name: string;
|
||||
config: string;
|
||||
platform?: string | null;
|
||||
computerName?: string | null;
|
||||
}
|
||||
|
||||
export type PresetUpdateInput = Partial<PresetCreateInput>;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type PresetRepository = ReturnType<typeof createPresetRepository>;
|
||||
|
||||
/**
|
||||
* Per-user client tunnel presets.
|
||||
*
|
||||
* No RETURNING, so it runs the same on all three engines: a new row is read
|
||||
* back as the newest row for that user and name, which the name check before
|
||||
* every insert keeps unique.
|
||||
*/
|
||||
export function createPresetRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<PresetRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return (rows[0] as PresetRecord) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
findByIdForUser,
|
||||
|
||||
async listByUserId(userId: string): Promise<PresetRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.orderBy(asc(table.name));
|
||||
},
|
||||
|
||||
async hasNameForUser(
|
||||
userId: string,
|
||||
name: string,
|
||||
excludingId?: number,
|
||||
): Promise<boolean> {
|
||||
const drizzle = await client();
|
||||
const conditions = [eq(table.userId, userId), eq(table.name, name)];
|
||||
if (excludingId !== undefined) conditions.push(ne(table.id, excludingId));
|
||||
const rows = await drizzle
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: PresetCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<PresetRecord> {
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(table).values({
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.name, input.name)))
|
||||
.limit(1);
|
||||
return rows[0] as PresetRecord;
|
||||
},
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: PresetUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<PresetRecord | null> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...updates, updatedAt: now })
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return findByIdForUser(userId, id);
|
||||
},
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
if (!(await findByIdForUser(userId, id))) return false;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
CONNECTION_STATES,
|
||||
type TunnelConnectRequest,
|
||||
type TunnelConnection,
|
||||
} from "./types.js";
|
||||
import type { TunnelManager } from "./manager.js";
|
||||
import { errorMessage } from "./manager.js";
|
||||
import type { PresetRecord, PresetRepository } from "./repository.js";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
isReservedTunnelName,
|
||||
validateTunnelConfig,
|
||||
} from "./utils.js";
|
||||
import { authorizeTunnelAction } from "./authorize.js";
|
||||
import { buildTunnelConfig, resolveEndpoint } from "./config.js";
|
||||
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function parsePreset(row: PresetRecord) {
|
||||
let config: TunnelConnection[] = [];
|
||||
try {
|
||||
config = JSON.parse(row.config) as TunnelConnection[];
|
||||
} catch {
|
||||
config = [];
|
||||
}
|
||||
return { ...row, config };
|
||||
}
|
||||
|
||||
/** Every item must be a client tunnel with a known mode and valid ports. */
|
||||
export function validatePresetConfig(
|
||||
config: unknown,
|
||||
): config is TunnelConnection[] {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const tunnel = item as Partial<TunnelConnection>;
|
||||
const mode = tunnel.mode || tunnel.tunnelType;
|
||||
return (
|
||||
tunnel.scope === "c2s" &&
|
||||
(mode === "local" || mode === "remote" || mode === "dynamic") &&
|
||||
typeof tunnel.sourcePort === "number" &&
|
||||
tunnel.sourcePort >= 1 &&
|
||||
tunnel.sourcePort <= 65535 &&
|
||||
(mode === "dynamic" ||
|
||||
(typeof tunnel.endpointPort === "number" &&
|
||||
tunnel.endpointPort >= 1 &&
|
||||
tunnel.endpointPort <= 65535))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function trimmedOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the tunnel routes on the plugin's router, which core serves at
|
||||
* /plugin-api/tunnels with auth in front. Every route needs tunnels.use.
|
||||
*/
|
||||
export function registerTunnelRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
manager: TunnelManager,
|
||||
presets: PresetRepository,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
const statusClients = new Map<Response, string>();
|
||||
|
||||
const sendSnapshot = (res: Response) => {
|
||||
const userId = statusClients.get(res);
|
||||
if (userId === undefined) return;
|
||||
void manager
|
||||
.statusesFor(userId)
|
||||
.then((snapshot) => {
|
||||
res.write(`event: statuses\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
||||
})
|
||||
.catch(() => {
|
||||
statusClients.delete(res);
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = manager.onChange(() => {
|
||||
for (const res of statusClients.keys()) sendSnapshot(res);
|
||||
});
|
||||
ctx.disposables.add(() => {
|
||||
unsubscribe();
|
||||
for (const res of statusClients.keys()) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
statusClients.clear();
|
||||
});
|
||||
|
||||
const canAccess = (userId: string) => (hostId: number) =>
|
||||
manager.canAccessHost(userId, hostId);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/status:
|
||||
* get:
|
||||
* summary: Get tunnel statuses
|
||||
* description: Returns the status of every tunnel whose source host the caller can reach.
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tunnel statuses keyed by tunnel name.
|
||||
* 403:
|
||||
* description: Missing the tunnels.use permission.
|
||||
*/
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
res.json(await manager.statusesFor(actor(ctx)));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/status/stream:
|
||||
* get:
|
||||
* summary: Stream tunnel statuses
|
||||
* description: Server-sent events. Sends a "statuses" event with the full snapshot the caller can see on connect and after every change.
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
* 403:
|
||||
* description: Missing the tunnels.use permission.
|
||||
*/
|
||||
router.get("/status/stream", (req: Request, res: Response) => {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
statusClients.set(res, actor(ctx));
|
||||
sendSnapshot(res);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
closeStream();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const closeStream = () => {
|
||||
clearInterval(heartbeat);
|
||||
statusClients.delete(res);
|
||||
};
|
||||
|
||||
req.on("close", closeStream);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/status/{tunnelName}:
|
||||
* get:
|
||||
* summary: Get one tunnel's status
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: tunnelName
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The tunnel's status.
|
||||
* 404:
|
||||
* description: No such tunnel, or the caller cannot see it.
|
||||
*/
|
||||
router.get("/status/:tunnelName", async (req: Request, res: Response) => {
|
||||
const tunnelName = String(req.params.tunnelName);
|
||||
// 404 rather than 403 for someone else's tunnel: the name itself carries
|
||||
// host details, so confirming it exists would leak them.
|
||||
if (!(await manager.canAccessTunnel(actor(ctx), tunnelName))) {
|
||||
return res.status(404).json({ error: "Tunnel not found" });
|
||||
}
|
||||
const status = manager.statuses.get(tunnelName);
|
||||
if (!status) return res.status(404).json({ error: "Tunnel not found" });
|
||||
res.json({ name: tunnelName, status });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/connect:
|
||||
* post:
|
||||
* summary: Connect a tunnel
|
||||
* description: Starts a server tunnel from one of the caller's hosts. Credentials are resolved on the server. The result arrives over the status stream.
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* sourceHostId:
|
||||
* type: integer
|
||||
* tunnelIndex:
|
||||
* type: integer
|
||||
* mode:
|
||||
* type: string
|
||||
* endpointHost:
|
||||
* type: string
|
||||
* sourcePort:
|
||||
* type: integer
|
||||
* endpointPort:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection request received.
|
||||
* 400:
|
||||
* description: Invalid tunnel configuration.
|
||||
* 403:
|
||||
* description: Access denied to this host.
|
||||
*/
|
||||
router.post("/connect", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const body = (req.body ?? {}) as Partial<TunnelConnectRequest>;
|
||||
const tunnelName = body.name;
|
||||
|
||||
if (!isNonEmptyString(tunnelName)) {
|
||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||
}
|
||||
|
||||
// Reserved for on-demand forwards, which skip retries by name. A saved
|
||||
// tunnel using it would silently lose its own reconnects.
|
||||
if (isReservedTunnelName(tunnelName)) {
|
||||
return res.status(400).json({
|
||||
error: `Tunnel names beginning with "${RESERVED_TUNNEL_NAME_PREFIX}" are reserved`,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceHostId = Number(body.sourceHostId);
|
||||
const tunnelIndex = Number(body.tunnelIndex ?? 0);
|
||||
if (!Number.isInteger(sourceHostId) || !Number.isInteger(tunnelIndex)) {
|
||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||
}
|
||||
|
||||
const request: TunnelConnectRequest = {
|
||||
...(body as TunnelConnectRequest),
|
||||
endpointHost: (body.endpointHost ?? "").trim(),
|
||||
name: tunnelName,
|
||||
sourceHostId,
|
||||
tunnelIndex,
|
||||
};
|
||||
|
||||
if (
|
||||
!validateTunnelConfig(tunnelName, {
|
||||
sourceHostId,
|
||||
tunnelIndex,
|
||||
sourcePort: request.sourcePort,
|
||||
endpointHost: request.endpointHost ?? "",
|
||||
endpointPort: request.endpointPort,
|
||||
})
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Tunnel configuration does not match tunnel name" });
|
||||
}
|
||||
|
||||
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
|
||||
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
|
||||
if (!host) {
|
||||
ctx.log.warn(
|
||||
`User ${userId} tried to connect tunnel ${tunnelName} without access to host ${sourceHostId}`,
|
||||
);
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await resolveEndpoint(
|
||||
ctx,
|
||||
buildTunnelConfig(host, request, userId),
|
||||
);
|
||||
await manager.start(config);
|
||||
} catch (error) {
|
||||
const reason = errorMessage(error);
|
||||
ctx.log.warn(`Tunnel ${tunnelName} could not start: ${reason}`);
|
||||
manager.broadcast(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Connection request received", tunnelName });
|
||||
});
|
||||
|
||||
const stopHandler =
|
||||
(message: string) => async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const tunnelName = (req.body ?? {}).tunnelName;
|
||||
if (!isNonEmptyString(tunnelName)) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
const decision = await authorizeTunnelAction(
|
||||
canAccess(userId),
|
||||
tunnelName,
|
||||
manager.configs.get(tunnelName),
|
||||
);
|
||||
if (!decision.allowed) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
await manager.stop(tunnelName);
|
||||
res.json({ message, tunnelName });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/disconnect:
|
||||
* post:
|
||||
* summary: Disconnect a tunnel
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tunnelName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Disconnect request received.
|
||||
* 400:
|
||||
* description: Tunnel name required.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
*/
|
||||
router.post("/disconnect", stopHandler("Disconnect request received"));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/cancel:
|
||||
* post:
|
||||
* summary: Cancel a tunnel's connect or retry
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tunnelName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Cancel request received.
|
||||
* 400:
|
||||
* description: Tunnel name required.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
*/
|
||||
router.post("/cancel", stopHandler("Cancel request received"));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/presets:
|
||||
* get:
|
||||
* summary: List client tunnel presets
|
||||
* description: Returns the caller's saved client-to-server tunnel presets.
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of tunnel presets.
|
||||
* 403:
|
||||
* description: Missing the tunnels.use permission.
|
||||
*/
|
||||
router.get("/presets", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await presets.listByUserId(actor(ctx));
|
||||
res.json(rows.map(parsePreset));
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to fetch client tunnel presets",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/presets:
|
||||
* post:
|
||||
* summary: Create a client tunnel preset
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* config:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* platform:
|
||||
* type: string
|
||||
* computerName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Preset created.
|
||||
* 400:
|
||||
* description: Invalid name or config.
|
||||
* 409:
|
||||
* description: A preset with that name already exists.
|
||||
*/
|
||||
router.post("/presets", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { name, config, platform, computerName } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
if (!validatePresetConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
try {
|
||||
if (await presets.hasNameForUser(userId, trimmedName)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
const created = await presets.createForUser(userId, {
|
||||
name: trimmedName,
|
||||
config: JSON.stringify(config),
|
||||
platform: trimmedOrNull(platform),
|
||||
computerName: trimmedOrNull(computerName),
|
||||
});
|
||||
res.status(201).json(parsePreset(created));
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to create client tunnel preset",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/presets/{id}:
|
||||
* put:
|
||||
* summary: Update a client tunnel preset
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preset updated.
|
||||
* 400:
|
||||
* description: Invalid name or config.
|
||||
* 404:
|
||||
* description: Preset not found.
|
||||
* 409:
|
||||
* description: A preset with that name already exists.
|
||||
*/
|
||||
router.put("/presets/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = Number(req.params.id);
|
||||
const { name, config, platform, computerName } = req.body ?? {};
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await presets.findByIdForUser(userId, id);
|
||||
if (!existing) return res.status(404).json({ error: "Preset not found" });
|
||||
|
||||
const updates: Parameters<PresetRepository["updateForUser"]>[2] = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
if (await presets.hasNameForUser(userId, trimmedName, id)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
updates.name = trimmedName;
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
if (!validatePresetConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
updates.config = JSON.stringify(config);
|
||||
}
|
||||
if (platform !== undefined) updates.platform = trimmedOrNull(platform);
|
||||
if (computerName !== undefined) {
|
||||
updates.computerName = trimmedOrNull(computerName);
|
||||
}
|
||||
|
||||
const updated = await presets.updateForUser(userId, id, updates);
|
||||
res.json(parsePreset(updated ?? existing));
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to update client tunnel preset",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tunnels/presets/{id}:
|
||||
* delete:
|
||||
* summary: Delete a client tunnel preset
|
||||
* tags:
|
||||
* - Tunnels
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preset deleted.
|
||||
* 404:
|
||||
* description: Preset not found.
|
||||
*/
|
||||
router.delete("/presets/:id", async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await presets.deleteForUser(actor(ctx), id))) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to delete client tunnel preset",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { TunnelConfig, TunnelStatus } from "./types.js";
|
||||
import type { TunnelManager, TunnelRuntime } from "./manager.js";
|
||||
import { forwardOut } from "./ssh-primitives.js";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
} from "./utils.js";
|
||||
import { authorizeTunnelAction } from "./authorize.js";
|
||||
import {
|
||||
buildTunnelConfig,
|
||||
connectionToRequest,
|
||||
findSavedTunnel,
|
||||
hostLabel,
|
||||
resolveEndpoint,
|
||||
} from "./config.js";
|
||||
|
||||
export interface TunnelForwardTarget {
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
/** Where the local listener binds. Defaults to 127.0.0.1. */
|
||||
bindHost?: string;
|
||||
/** A fixed local port. Defaults to one the kernel picks. */
|
||||
bindPort?: number;
|
||||
}
|
||||
|
||||
export interface TunnelForwardHandle {
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** What other plugins get from ctx.services.get("tunnels.access"). */
|
||||
export interface TunnelsAccess {
|
||||
/**
|
||||
* Opens (or reuses) a local forward to target through a source host and
|
||||
* resolves only once it works: connected, listening and the target
|
||||
* answered one probe. Rejects with the real reason otherwise.
|
||||
*/
|
||||
forward(
|
||||
sourceHostId: number,
|
||||
target: TunnelForwardTarget,
|
||||
options?: { name?: string; idleTimeoutMs?: number },
|
||||
): Promise<TunnelForwardHandle>;
|
||||
/** Starts a saved tunnel by name. Its outcome arrives as status. */
|
||||
start(name: string): Promise<void>;
|
||||
/** Stops a tunnel by name and holds off its retries. */
|
||||
stop(name: string): Promise<void>;
|
||||
status(name: string): Promise<TunnelStatus | null>;
|
||||
list(): Promise<Record<string, TunnelStatus>>;
|
||||
}
|
||||
|
||||
const TARGET_PROBE_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Opens one channel to the target and closes it again. Without it a forward
|
||||
* reports success the moment its listener is up, and the likeliest failure
|
||||
* (nothing listening on the target port) only shows up as a blank page.
|
||||
*/
|
||||
async function probeTarget(
|
||||
client: Client,
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const channel = await Promise.race([
|
||||
forwardOut(client, targetHost, targetPort),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Timed out reaching the endpoint port")),
|
||||
TARGET_PROBE_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
channel.end();
|
||||
} catch {
|
||||
// Already gone; the probe succeeded either way.
|
||||
}
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isPort(value: unknown, allowZero = false): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= (allowZero ? 0 : 1) &&
|
||||
value <= 65535
|
||||
);
|
||||
}
|
||||
|
||||
export function createTunnelsService(
|
||||
ctx: PluginContext,
|
||||
manager: TunnelManager,
|
||||
): TunnelsAccess {
|
||||
// What each live forward was opened for, so an edited target reopens it
|
||||
// instead of silently forwarding to the old one.
|
||||
const fingerprints = new Map<string, string>();
|
||||
|
||||
const actor = (): string => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("No acting user");
|
||||
return userId;
|
||||
};
|
||||
|
||||
const handleFor = (
|
||||
name: string,
|
||||
runtime: TunnelRuntime,
|
||||
): TunnelForwardHandle => ({
|
||||
bindHost: runtime.bindHost,
|
||||
bindPort: runtime.bindPort,
|
||||
close: async () => {
|
||||
fingerprints.delete(name);
|
||||
await manager.cleanup(name, true);
|
||||
},
|
||||
});
|
||||
|
||||
const canAccess = (hostId: number) => manager.canAccessHost(actor(), hostId);
|
||||
|
||||
return {
|
||||
async forward(sourceHostId, target, options = {}) {
|
||||
const userId = actor();
|
||||
if (!isPort(target.targetPort)) throw new Error("Invalid target port");
|
||||
if (target.bindPort !== undefined && !isPort(target.bindPort, true)) {
|
||||
throw new Error("Invalid bind port");
|
||||
}
|
||||
const targetHost = target.targetHost?.trim();
|
||||
if (!targetHost) throw new Error("Invalid target host");
|
||||
|
||||
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
|
||||
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
|
||||
if (!host) throw new Error("Host not found or access denied");
|
||||
|
||||
const name =
|
||||
options.name ??
|
||||
`${RESERVED_TUNNEL_NAME_PREFIX}${sourceHostId}:${targetHost}:${target.targetPort}`;
|
||||
if (
|
||||
!isReservedTunnelName(name) ||
|
||||
parseReservedTunnelName(name)?.hostId !== sourceHostId
|
||||
) {
|
||||
throw new Error(
|
||||
`Forward names must be "${RESERVED_TUNNEL_NAME_PREFIX}<hostId>:<id>" for the source host`,
|
||||
);
|
||||
}
|
||||
|
||||
const bindHost = target.bindHost?.trim() || "127.0.0.1";
|
||||
const fingerprint = JSON.stringify({
|
||||
targetHost,
|
||||
targetPort: target.targetPort,
|
||||
bindHost,
|
||||
bindPort: target.bindPort ?? null,
|
||||
ip: host.ip,
|
||||
sshPort: host.port,
|
||||
username: host.username,
|
||||
});
|
||||
|
||||
const existing = manager.runtimes.get(name);
|
||||
if (existing) {
|
||||
const recorded = fingerprints.get(name);
|
||||
if (recorded === undefined || recorded === fingerprint) {
|
||||
fingerprints.set(name, fingerprint);
|
||||
return handleFor(name, existing);
|
||||
}
|
||||
ctx.log.info(`Reopening tunnel ${name} after its target changed`);
|
||||
await manager.cleanup(name, true);
|
||||
fingerprints.delete(name);
|
||||
}
|
||||
|
||||
const config: TunnelConfig = {
|
||||
name,
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost,
|
||||
targetHost,
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: 0,
|
||||
requestingUserId: userId,
|
||||
hostName: hostLabel(host),
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
// Loopback marks it single host, which is what picks the
|
||||
// listen-here-and-forwardOut path.
|
||||
endpointHost: "127.0.0.1",
|
||||
sourcePort: target.bindPort ?? 0,
|
||||
endpointPort: target.targetPort,
|
||||
maxRetries: 0,
|
||||
retryInterval: 0,
|
||||
autoStart: false,
|
||||
idleTimeoutMs: options.idleTimeoutMs,
|
||||
};
|
||||
|
||||
manager.manualDisconnects.delete(name);
|
||||
try {
|
||||
const runtime = await manager.connect(config, 0, {
|
||||
throwOnError: true,
|
||||
});
|
||||
if (!runtime) {
|
||||
throw new Error("The tunnel closed before it could be used");
|
||||
}
|
||||
await probeTarget(runtime.sourceClient, targetHost, target.targetPort);
|
||||
fingerprints.set(name, fingerprint);
|
||||
return handleFor(name, runtime);
|
||||
} catch (error) {
|
||||
// Nothing half-built may be handed out on the next call.
|
||||
await manager.cleanup(name, true).catch(() => undefined);
|
||||
fingerprints.delete(name);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async start(name) {
|
||||
const existing = manager.configs.get(name);
|
||||
if (existing) {
|
||||
if (!(await canAccess(existing.sourceHostId))) {
|
||||
throw new Error(`Tunnel "${name}" is not configured`);
|
||||
}
|
||||
await manager.start(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await findSavedTunnel(ctx, name);
|
||||
if (!saved) throw new Error(`Tunnel "${name}" is not configured`);
|
||||
const config = await resolveEndpoint(
|
||||
ctx,
|
||||
buildTunnelConfig(
|
||||
saved.host,
|
||||
connectionToRequest(saved.host, saved.index, saved.connection),
|
||||
actor(),
|
||||
),
|
||||
);
|
||||
await manager.start(config);
|
||||
},
|
||||
|
||||
async stop(name) {
|
||||
const decision = await authorizeTunnelAction(
|
||||
canAccess,
|
||||
name,
|
||||
manager.configs.get(name),
|
||||
);
|
||||
if (!decision.allowed) throw new Error("Access denied");
|
||||
await manager.stop(name);
|
||||
},
|
||||
|
||||
async status(name) {
|
||||
if (!(await manager.canAccessTunnel(actor(), name))) return null;
|
||||
return manager.statuses.get(name) ?? null;
|
||||
},
|
||||
|
||||
async list() {
|
||||
return manager.statusesFor(actor());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
function parseSocksAddress(buffer: Buffer): {
|
||||
address: string;
|
||||
port: number;
|
||||
bytesRead: number;
|
||||
} | null {
|
||||
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addressType = buffer[3];
|
||||
let offset = 4;
|
||||
let address: string;
|
||||
|
||||
if (addressType === 0x01) {
|
||||
if (buffer.length < offset + 4 + 2) return null;
|
||||
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
||||
offset += 4;
|
||||
} else if (addressType === 0x03) {
|
||||
const len = buffer[offset];
|
||||
offset += 1;
|
||||
if (buffer.length < offset + len + 2) return null;
|
||||
address = buffer.subarray(offset, offset + len).toString("utf8");
|
||||
offset += len;
|
||||
} else if (addressType === 0x04) {
|
||||
if (buffer.length < offset + 16 + 2) return null;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
||||
}
|
||||
address = parts.join(":");
|
||||
offset += 16;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = buffer.readUInt16BE(offset);
|
||||
return { address, port, bytesRead: offset + 2 };
|
||||
}
|
||||
|
||||
/** The server side of a SOCKS5 CONNECT, for dynamic mode tunnels. */
|
||||
export function handleSocks5Connect(
|
||||
inbound: Duplex,
|
||||
openOutbound: (host: string, port: number) => Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
log?: PluginLogger,
|
||||
): void {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let stage: "greeting" | "connect" | "piping" = "greeting";
|
||||
|
||||
const fail = (code = 0x01) => {
|
||||
if (!inbound.destroyed) {
|
||||
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
inbound.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
if (stage === "greeting") {
|
||||
if (buffer.length < 2) return;
|
||||
if (buffer[0] !== 0x05) {
|
||||
fail(0x01);
|
||||
return;
|
||||
}
|
||||
const methodsLength = buffer[1];
|
||||
if (buffer.length < 2 + methodsLength) return;
|
||||
inbound.write(Buffer.from([0x05, 0x00]));
|
||||
buffer = buffer.subarray(2 + methodsLength);
|
||||
stage = "connect";
|
||||
}
|
||||
|
||||
if (stage === "connect") {
|
||||
const parsed = parseSocksAddress(buffer);
|
||||
if (!parsed) return;
|
||||
stage = "piping";
|
||||
inbound.off("data", onData);
|
||||
const remainder = buffer.subarray(parsed.bytesRead);
|
||||
openOutbound(parsed.address, parsed.port)
|
||||
.then((outbound) => {
|
||||
inbound.write(
|
||||
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
if (remainder.length > 0) {
|
||||
outbound.write(remainder);
|
||||
}
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log?.warn(
|
||||
`SOCKS5 connect to ${parsed.address}:${parsed.port} failed for tunnel ${tunnelName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
fail(0x05);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
inbound.on("data", onData);
|
||||
inbound.on("error", () => inbound.destroy());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
// Plain ssh2 channel operations on a client that already came from ctx.ssh.
|
||||
|
||||
export function forwardOut(
|
||||
client: Client,
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
): Promise<ClientChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function bindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(actualPort || bindPort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
log?: PluginLogger,
|
||||
): void {
|
||||
try {
|
||||
client.unforwardIn(bindHost, bindPort, (err) => {
|
||||
if (err) {
|
||||
log?.warn(
|
||||
`Failed to unbind tunnel listener ${bindHost}:${bindPort}: ${err.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// The connection may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
export function pipeTunnelStreams(
|
||||
inbound: Duplex,
|
||||
outboundPromise: Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
log?: PluginLogger,
|
||||
): void {
|
||||
outboundPromise
|
||||
.then((outbound) => {
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log?.warn(
|
||||
`Tunnel ${tunnelName} could not open its outbound stream: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
inbound.destroy();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Client tunnel presets the desktop app saves per user.
|
||||
*
|
||||
* Adopted from core's c2s_tunnel_presets, so the column names are the legacy
|
||||
* ones and the rename carries every existing row across. Core never indexed
|
||||
* it, so there is no legacy index to keep.
|
||||
*/
|
||||
export const presets = adoptLegacyTable(
|
||||
"c2s_tunnel_presets",
|
||||
defineTable("presets", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
// JSON: TunnelConnection[]
|
||||
config: text().notNull(),
|
||||
platform: text(),
|
||||
computerName: text(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [presets];
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TunnelMode, TunnelScope } from "../shared/types.js";
|
||||
|
||||
export type {
|
||||
C2STunnelPreset,
|
||||
ConnectionState,
|
||||
TunnelConnectRequest,
|
||||
TunnelConnection,
|
||||
TunnelErrorType,
|
||||
TunnelMode,
|
||||
TunnelScope,
|
||||
TunnelStatus,
|
||||
} from "../shared/types.js";
|
||||
export { CONNECTION_STATES } from "../shared/types.js";
|
||||
|
||||
/**
|
||||
* A tunnel as the manager runs it. Built server side from a saved connection
|
||||
* or a connect request; it never carries credentials; both SSH legs resolve
|
||||
* theirs through ctx.ssh.
|
||||
*/
|
||||
export interface TunnelConfig {
|
||||
name: string;
|
||||
scope?: TunnelScope;
|
||||
mode?: TunnelMode;
|
||||
tunnelType?: "local" | "remote";
|
||||
localAddress?: string;
|
||||
remoteAddress?: string;
|
||||
bindHost?: string;
|
||||
targetHost?: string;
|
||||
|
||||
sourceHostId: number;
|
||||
sourceHostSyncId?: string;
|
||||
tunnelIndex: number;
|
||||
/** Who the SSH connections run as. */
|
||||
requestingUserId: string;
|
||||
|
||||
hostName: string;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
|
||||
endpointHost: string;
|
||||
/** The Termix host the endpoint leg connects to, when there is one. */
|
||||
endpointHostId?: number;
|
||||
endpointIP?: string;
|
||||
endpointSSHPort?: number;
|
||||
endpointUsername?: string;
|
||||
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
maxRetries: number;
|
||||
/** Milliseconds. */
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
|
||||
/**
|
||||
* When set, the tunnel closes itself once it has had no connected sockets
|
||||
* for this long. forward() uses it for on-demand tunnels.
|
||||
*/
|
||||
idleTimeoutMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { buildTunnelName } from "../shared/tunnel-naming.js";
|
||||
import type { TunnelConfig, TunnelErrorType } from "./types.js";
|
||||
|
||||
export function classifyTunnelError(errorMessage: string): TunnelErrorType {
|
||||
if (!errorMessage) return "UNKNOWN";
|
||||
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("closed by remote host") ||
|
||||
message.includes("connection reset by peer") ||
|
||||
message.includes("connection refused") ||
|
||||
message.includes("broken pipe")
|
||||
) {
|
||||
return "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("authentication failed") ||
|
||||
message.includes("authentication methods failed") ||
|
||||
message.includes("permission denied") ||
|
||||
message.includes("incorrect password")
|
||||
) {
|
||||
return "AUTHENTICATION_FAILED";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")
|
||||
) {
|
||||
return "TIMEOUT";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("bind: address already in use") ||
|
||||
message.includes("failed for listen port") ||
|
||||
message.includes("port forwarding failed")
|
||||
) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
if (message.includes("permission") || message.includes("access denied")) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
/** The name a host's saved tunnel runs under. See shared/tunnel-naming.ts. */
|
||||
export const normalizeTunnelName = buildTunnelName;
|
||||
|
||||
export function getTunnelMode(
|
||||
tunnelConfig: Pick<TunnelConfig, "mode" | "tunnelType">,
|
||||
): "local" | "remote" | "dynamic" {
|
||||
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
|
||||
}
|
||||
|
||||
export function getTunnelScope(
|
||||
tunnelConfig: Pick<TunnelConfig, "scope">,
|
||||
): "s2s" | "c2s" {
|
||||
return tunnelConfig.scope || "s2s";
|
||||
}
|
||||
|
||||
export function getTunnelBindHost(
|
||||
tunnelConfig: Pick<TunnelConfig, "bindHost">,
|
||||
): string {
|
||||
return tunnelConfig.bindHost || "127.0.0.1";
|
||||
}
|
||||
|
||||
export function parseTunnelName(tunnelName: string): {
|
||||
hostId?: number;
|
||||
tunnelIndex?: number;
|
||||
displayName: string;
|
||||
sourcePort: string;
|
||||
endpointHost: string;
|
||||
endpointPort: string;
|
||||
isLegacyFormat: boolean;
|
||||
} {
|
||||
const parts = tunnelName.split("::");
|
||||
|
||||
if (parts.length === 6) {
|
||||
return {
|
||||
hostId: parseInt(parts[0]),
|
||||
tunnelIndex: parseInt(parts[1]),
|
||||
displayName: parts[2],
|
||||
sourcePort: parts[3],
|
||||
endpointHost: parts[4],
|
||||
endpointPort: parts[5],
|
||||
isLegacyFormat: false,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyParts = tunnelName.split("_");
|
||||
return {
|
||||
displayName: legacyParts[0] || "unknown",
|
||||
sourcePort: legacyParts[legacyParts.length - 3] || "0",
|
||||
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
|
||||
endpointPort: legacyParts[legacyParts.length - 1] || "0",
|
||||
isLegacyFormat: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTunnelConfig(
|
||||
tunnelName: string,
|
||||
tunnelConfig: Pick<
|
||||
TunnelConfig,
|
||||
| "sourceHostId"
|
||||
| "tunnelIndex"
|
||||
| "sourcePort"
|
||||
| "endpointHost"
|
||||
| "endpointPort"
|
||||
>,
|
||||
): boolean {
|
||||
const parsed = parseTunnelName(tunnelName);
|
||||
|
||||
if (parsed.isLegacyFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.hostId === tunnelConfig.sourceHostId &&
|
||||
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
|
||||
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
|
||||
parsed.endpointHost === tunnelConfig.endpointHost &&
|
||||
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunnel names beginning with this prefix are reserved for forward() tunnels
|
||||
* (web endpoints): they are opened on demand and never retried on disconnect.
|
||||
* A user-supplied name using the prefix would silently lose its own retry
|
||||
* behaviour and could collide with a live forward, so POST /connect rejects
|
||||
* it.
|
||||
*/
|
||||
export const RESERVED_TUNNEL_NAME_PREFIX = "web:";
|
||||
|
||||
export function isReservedTunnelName(tunnelName: string): boolean {
|
||||
return tunnelName.startsWith(RESERVED_TUNNEL_NAME_PREFIX);
|
||||
}
|
||||
|
||||
/** The exact inverse of parseReservedTunnelName. */
|
||||
export function buildWebEndpointTunnelName(
|
||||
hostId: number,
|
||||
endpointId: string,
|
||||
): string {
|
||||
return `${RESERVED_TUNNEL_NAME_PREFIX}${hostId}:${endpointId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers the host id and endpoint id from a reserved name, so a route that
|
||||
* only has the name can still check ownership.
|
||||
*
|
||||
* Returns null for anything that is not a reserved name or whose host id is
|
||||
* not a positive integer. Callers must treat null as "cannot verify" and deny.
|
||||
* The endpoint id is everything after the first ":" following the host id,
|
||||
* so it may contain colons itself.
|
||||
*/
|
||||
export function parseReservedTunnelName(
|
||||
tunnelName: string,
|
||||
): { hostId: number; endpointId: string } | null {
|
||||
if (!isReservedTunnelName(tunnelName)) return null;
|
||||
|
||||
const rest = tunnelName.slice(RESERVED_TUNNEL_NAME_PREFIX.length);
|
||||
const separatorIndex = rest.indexOf(":");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const hostIdPart = rest.slice(0, separatorIndex);
|
||||
const endpointId = rest.slice(separatorIndex + 1);
|
||||
if (!/^[0-9]+$/.test(hostIdPart) || !endpointId) return null;
|
||||
|
||||
const hostId = Number(hostIdPart);
|
||||
if (!Number.isInteger(hostId) || hostId < 1) return null;
|
||||
|
||||
return { hostId, endpointId };
|
||||
}
|
||||
|
||||
type EndpointHostLike = {
|
||||
id: number | string;
|
||||
name?: string | null;
|
||||
ip: string;
|
||||
username?: string | null;
|
||||
};
|
||||
|
||||
/** Matches a saved endpointHost string against a host id, name, ip or user@ip. */
|
||||
export function findHostByTunnelEndpoint<T extends EndpointHostLike>(
|
||||
hosts: T[],
|
||||
endpointHost?: string | null,
|
||||
): T | undefined {
|
||||
const value = endpointHost?.trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
return hosts.find((host) => {
|
||||
const userAtIp = host.username ? `${host.username}@${host.ip}` : "";
|
||||
return (
|
||||
String(host.id) === value ||
|
||||
host.name === value ||
|
||||
host.ip === value ||
|
||||
userAtIp === value
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isSingleHostTunnel(
|
||||
tunnelConfig: Pick<
|
||||
TunnelConfig,
|
||||
| "endpointHost"
|
||||
| "endpointIP"
|
||||
| "sourceIP"
|
||||
| "endpointSSHPort"
|
||||
| "sourceSSHPort"
|
||||
| "endpointHostId"
|
||||
| "sourceHostId"
|
||||
>,
|
||||
): boolean {
|
||||
if (!tunnelConfig.endpointHost && !tunnelConfig.endpointIP) return true;
|
||||
if (
|
||||
tunnelConfig.endpointHost === "127.0.0.1" ||
|
||||
tunnelConfig.endpointHost === "localhost"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
tunnelConfig.endpointHostId !== undefined &&
|
||||
tunnelConfig.endpointHostId === tunnelConfig.sourceHostId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
tunnelConfig.endpointIP &&
|
||||
tunnelConfig.endpointIP === tunnelConfig.sourceIP &&
|
||||
tunnelConfig.endpointSSHPort === tunnelConfig.sourceSSHPort
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct tunnels ride the source connection alone. Anything else needs a
|
||||
* second SSH leg to a Termix endpoint host, reached through the source.
|
||||
*/
|
||||
export function shouldEstablishDirectTunnel(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): boolean {
|
||||
if (isSingleHostTunnel(tunnelConfig)) return true;
|
||||
const mode = getTunnelMode(tunnelConfig);
|
||||
return mode !== "remote" && tunnelConfig.endpointHostId === undefined;
|
||||
}
|
||||
|
||||
export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string {
|
||||
const targetHost = tunnelConfig.targetHost?.trim();
|
||||
|
||||
if (
|
||||
!targetHost ||
|
||||
targetHost === tunnelConfig.endpointHost ||
|
||||
targetHost === tunnelConfig.hostName
|
||||
) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
return targetHost;
|
||||
}
|
||||
Reference in new issue
Block a user