feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

+26 -5
View File
@@ -433,7 +433,10 @@ and `session.remoteDisplay`. `host-metrics.managers` (component slot,
**B3**) is host-metrics's own: tailscale contributes its manager card there
instead of host-metrics knowing tailscale exists, and host-metrics works with
or without tailscale enabled. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
`automations.list`, `fleets.list`, `session.remoteDisplay.token`. Core calls
plugin actions the same way when a core view shows a plugin's data:
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
dashboard's active tunnel counter, which reads as zero while tunnels is off.
**B2** added a live-terminal-session surface, for a plugin that needs to push
resolved text into an open SSH session rather than just fill a slot with a
@@ -499,6 +502,15 @@ the caller. The workspaces plugin provides `workspaces.saved` (`list()`), which
the AI assistant's `list_workspaces` tool reaches through an optional entry in
its `requires`, answering `unavailable: true` while workspaces is off.
**B7**'s `tunnels.access` is the first service with a hard consumer:
web-endpoint lists tunnels in `dependencies` and a non-optional `requires`,
because its tunnel endpoints cannot work without it, while automations keeps
it optional for its one tunnel step. `forward()` resolves only once the
forward is listening and the target answered a probe, so a caller never has
to poll the plugin's state. A plugin can also listen for another plugin's
`plugin.<id>.*` events without any capability: automations turns
`plugin.tunnels.tunnel_disconnected` into its `tunnel_disconnected` trigger.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -1076,6 +1088,13 @@ connect pipeline instead of importing ssh2 helpers from core:
since the file-manager plugin's interactive connect route and its dedicated
transfer sessions both need their own keepalive/timeout defaults rather than
falling back to the generic `"plugin"` purpose.
- **B7** added `sock` to the connect options: an already-open stream to the
host, such as a `forwardOut` channel through another host. `connect` then
skips the transport step (port knocking, proxy, jump hosts, DNS) and runs
the host key check and auth over that stream. The tunnels plugin opens the
endpoint leg of a source-to-endpoint tunnel this way, so both legs go
through the pipeline; before B7 tunnels built their own ssh2 `Client` and
never checked host keys at all.
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
@@ -1644,10 +1663,12 @@ Known specifics:
degrade silently when automations is absent, so host-metrics declares it as
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its
nginx block until its own Phase B step. No plugin owns a port any more (A4).
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`.
- Core feature servers that are not plugins yet still own ports: dashboard
30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx
block until its own Phase B step. No plugin owns a port any more (A4).
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`,
and **B7** moved tunnels off port 30003 onto `/plugin-api/tunnels/` and
`/plugin-ws/tunnels/c2s/stream`.
- The terminal component and `TerminalTabContent` stay in core until the
terminal's Phase B step (session manager, split view). ssh-terminal
registers them through the legacy alias; **B6** switched every core caller
+30
View File
@@ -80,3 +80,33 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
dialect, `schema:generate`) is real, separate work this step didn't
attempt, since none of those reads block the plugin conversion itself.
Owner: a dedicated follow-up step, or D0.
- **B7 (tunnels):** `enable_tunnel` and `tunnel_connections` are still live
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
(`tunnels-settings-migration.ts`), and the plugin reads its host settings
now. Core still carries the columns through the host routes, the
encrypt/decrypt round trip, `SidebarTree.tsx`'s host duplicate payload, the
quick-connect mapping and the `Host`/`SSHHost` types, none of which the
plugin reads any more. Dropping them in lockstep (`schema.ts`, `db/index.ts`,
a drizzle migration per dialect, `schema:generate`) is the same follow-up the
B6 file-manager line above describes. Owner: a dedicated follow-up step, or D0.
- **B7 (tunnels):** host export/import, host duplicate, the Hosts panel
feature filter and the bulk enable/disable menu all work off `ssh_data`
columns, so none of them can see a plugin's host settings. For tunnels the
"Tunnel" filter and the bulk enable/disable entries were removed, and the
export's "Tunnels" group became "Proxy" (SOCKS5 fields only) since
`tunnelConnections` there is now stale. Bringing these back needs a way for
core to read and write host-scope plugin settings generically (keyed off
`contributes.settings.host` or `hostCapability`) rather than per column.
Owner: D1.
- **B7 (tunnels):** the tunnel tab no longer writes a recent-activity entry:
`logActivity` is a core `@/main-axios` call and the plugin imports nothing
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would
restore it for this plugin and let docker, file-manager and host-metrics drop
their own `@/main-axios` import for it too. Owner: D1.
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected
remote server are no longer merged into the tab: the old code polled the
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no
remote-origin plugin API client. `subscribeTunnelStatuses` already takes a
`fetchRemote` and merges with local statuses winning, so it only needs a
client for `/plugin-api/tunnels/status` on the remote server. Owner: D1.
+6
View File
@@ -661,6 +661,12 @@ export interface PluginSshConnectOptions {
prompt?: PluginSshPromptChannel;
/** Config fields to force, e.g. a longer readyTimeout. */
overrides?: Record<string, unknown>;
/**
* An already-open stream to the host, such as a forwardOut channel through
* another host. The transport step is skipped: no port knocking, proxy,
* jump hosts or DNS lookup. Host key checks and auth still run.
*/
sock?: unknown;
}
export interface PluginSshConnection<Client = unknown> {
+8 -3
View File
@@ -22,6 +22,7 @@ import type {
PluginLoginMethod,
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshConnectOptions,
PluginSshHost,
PluginHostSummary,
PluginHostRecord,
@@ -94,7 +95,11 @@ export interface FakePluginContext {
/** Core settings readable through ctx.settings.readCore. */
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
sshConnections: Array<{
host: number | PluginSshHost;
pool?: string;
options?: PluginSshConnectOptions;
}>;
/** Every ctx.hosts.share call, in order. */
hostShares: Array<{
hostId: number;
@@ -467,8 +472,8 @@ export function createFakeContext(
},
ssh: {
connect: async (host) => {
sshConnections.push({ host });
connect: async (host, connectOptions) => {
sshConnections.push({ host, options: connectOptions });
return {
client: sshClient as never,
jumpClient: null,