From d2ff5fc78f98c9b4114b6ced7cdb1a76b1b9ad55 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 09:41:20 -0500 Subject: [PATCH] feat: finish the tailscale plugin, including SSH auth (B3) --- packages/plugin-sdk/ARCHITECTURE.md | 25 +- packages/plugin-sdk/FINISH-LIST.md | 19 ++ packages/plugin-sdk/src/backend.ts | 26 ++ packages/plugin-sdk/src/frontend.ts | 12 + packages/plugin-sdk/src/testing/setup.ts | 1 + plugins/host-metrics/locales/en.json | 19 +- plugins/host-metrics/manifest.json | 8 +- .../src/backend/managers/index.ts | 2 - .../src/backend/managers/tailscale.ts | 177 ------------ .../src/frontend/HostMetricsTab.tsx | 43 ++- .../host-metrics/src/frontend/cards/index.tsx | 43 ++- plugins/host-metrics/src/frontend/index.tsx | 5 + plugins/tailscale/locales/en.json | 19 ++ plugins/tailscale/manifest.json | 12 +- .../src/backend/host-metrics-manager.ts | 269 ++++++++++++++++++ plugins/tailscale/src/backend/index.ts | 11 +- plugins/tailscale/src/backend/routes.ts | 235 +++++++-------- .../src/backend/ssh-auth-provider.ts | 74 +++++ plugins/tailscale/src/backend/ssh.ts | 27 ++ .../tailscale/src/backend}/tailscale-check.ts | 3 + .../src/frontend}/TailscaleManagerCard.tsx | 61 ++-- .../src/frontend/TailscaleManagerShell.tsx | 110 +++++++ plugins/tailscale/src/frontend/index.tsx | 11 + .../src/frontend/useTailscaleManager.ts | 105 +++++++ .../backend/host-metrics-manager.test.ts | 55 ++++ .../tests/backend/permissions.test.ts | 14 +- .../tests/backend/ssh-auth-provider.test.ts | 181 ++++++++++++ .../tests/backend/tailscale-check.test.ts | 2 +- scripts/plugin-boundary-allowlist.json | 3 +- src/backend/auth/legacy-providers.ts | 49 ---- src/backend/hosts/connect/types.ts | 28 ++ src/backend/hosts/terminal/index.ts | 75 +++-- src/backend/plugins/ctx-ssh-auth.ts | 5 + .../tests/hosts/connect/pipeline.test.ts | 45 +-- src/ui/plugin-host/bridge.ts | 3 + 35 files changed, 1251 insertions(+), 526 deletions(-) create mode 100644 packages/plugin-sdk/FINISH-LIST.md delete mode 100644 plugins/host-metrics/src/backend/managers/tailscale.ts create mode 100644 plugins/tailscale/src/backend/host-metrics-manager.ts create mode 100644 plugins/tailscale/src/backend/ssh-auth-provider.ts create mode 100644 plugins/tailscale/src/backend/ssh.ts rename {src/backend/hosts => plugins/tailscale/src/backend}/tailscale-check.ts (90%) rename plugins/{host-metrics/src/frontend/cards/managers => tailscale/src/frontend}/TailscaleManagerCard.tsx (77%) create mode 100644 plugins/tailscale/src/frontend/TailscaleManagerShell.tsx create mode 100644 plugins/tailscale/src/frontend/useTailscaleManager.ts create mode 100644 plugins/tailscale/tests/backend/host-metrics-manager.test.ts create mode 100644 plugins/tailscale/tests/backend/ssh-auth-provider.test.ts diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index f1f1d43aa..fface6007 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -417,13 +417,21 @@ boundary and Suspense. Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short name resolves to `.`), `useSettings`, `useHost`, `useHosts`, `useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`, -`useSshAuthTypes` (every SSH auth type the server knows, with its plugin). The SDK -has no runtime dependencies: the hooks delegate to a host bridge core installs. +`useSshAuthTypes` (every SSH auth type the server knows, with its plugin), +`useSlotContributions` (the visible contributions to a slot another plugin +owns, with their metadata, for a slot owner that builds a catalog from what +was contributed rather than just rendering it in place - host-metrics uses it +to list manager cards plugins added to `host-metrics.managers` next to its +own). The SDK has no runtime dependencies: the hooks delegate to a host +bridge core installs. Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay` (declared by ssh-terminal), `onboarding.steps`, `onboarding.features`, `onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor` -and `session.remoteDisplay`. Cross-plugin frontend calls go through actions: +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`. **B2** added a live-terminal-session surface, for a plugin that needs to push @@ -610,6 +618,15 @@ ctx.auth.registerSshAuthProvider({ }, onKeyboardInteractive: (round, host) => null, // claim a prompt style onAuthFailed: (host, env, context) => undefined, // clear caches, retry once + onBanner: (banner, host, env) => undefined, + // claim an ssh2 "banner" event during the handshake, for a server that + // holds the connection open pending an out-of-band step (Tailscale SSH + // check mode). Return { action: "hold", timeoutMs, message, details? } to + // extend the connect timeout and have the terminal send + // "_check_required" with `details` merged in, or + // { action: "release", details? } once the banner says it is done, which + // sends "_check_completed". Undefined leaves the banner unhandled. + // B3 (tailscale) is the only caller today. startInteraction: async (request) => {}, // start the browser step }); ``` @@ -1539,7 +1556,7 @@ What the lint fence enforces today, in `eslint.config.mjs`: | Core importing a plugin backend | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | The shell importing plugin code | **Error** | 0 | - | -| A plugin frontend importing core through `@/` | Warning | 107 files | D1 | +| A plugin frontend importing core through `@/` | Warning | 108 files | D1 | | A plugin importing core by relative path | Warning | 67 files | D1 | | A plugin importing another plugin's source | Warning | 3 files | B18 | diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md new file mode 100644 index 000000000..b4b48ad37 --- /dev/null +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -0,0 +1,19 @@ +# Finish list + +Running list of things intentionally left unfinished by a Phase B/C step, +so D0 (or an earlier owner named below) can pick them up. Remove a line once +it's done. + +- **B3 (tailscale):** `ManagerCardShell`/`ManagerSearch`-equivalent presentation + is duplicated as `TailscaleManagerShell`/`TailscaleManagerSearch` in + `plugins/tailscale/src/frontend/TailscaleManagerShell.tsx` rather than + shared with host-metrics's originals. Promote a shared version into + `@termix/plugin-sdk/ui` once a second manager-card plugin needs one, per the + contract's "add to the SDK when a second plugin needs it" rule. Owner: D1. +- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts` + and `routes.ts` still resolve hosts and access through core's + `PermissionManager`/`resolveHostById`/`DataCrypto` by relative import + instead of a `ctx.hosts`-style SDK API, because `ctx.hosts` is not built yet + (status **B** in the ctx surface table). Same debt every other host-metrics + manager already carries; not new, but tailscale now carries it too. Owner: + whichever step builds `ctx.hosts`. diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 1a28a988e..5c64976d8 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -448,6 +448,20 @@ export type PluginSshAuthOutcome = message: string; }; +export type PluginSshBannerDecision = + | { + /** Hold the connect timeout open and wait for the out-of-band step. */ + action: "hold"; + timeoutMs: number; + message: string; + details?: Record; + } + | { + /** The out-of-band step finished; resume the normal connect timeout. */ + action: "release"; + details?: Record; + }; + export interface PluginKeyboardInteractivePrompt { prompt: string; echo?: boolean; @@ -644,6 +658,18 @@ export interface PluginSshAuthProvider { methodNotAvailable: boolean; }, ) => PluginSshAuthOutcome | undefined; + /** + * Claims an ssh2 "banner" event during the handshake, for a server that + * holds the connection open pending an out-of-band step (Tailscale SSH + * check mode). A transport that can show this sends `_check_required` + * / `_check_completed` over its socket, with `details` merged in. + * Return nothing to leave the banner unhandled. + */ + onBanner?: ( + banner: string, + host: PluginSshHost, + env: PluginSshAuthEnv, + ) => PluginSshBannerDecision | undefined; /** Starts the browser step behind an interaction-required outcome. */ startInteraction?: (request: { userId: string; diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 29c119390..f32955c49 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -610,6 +610,7 @@ export interface PluginHostBridge { useTabs: () => TabsApi; invokeAction: (id: string, ...args: unknown[]) => Promise; useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean }; + useSlotContributions: (slotId: string) => SlotContribution[]; } let host: PluginHostBridge | null = null; @@ -712,6 +713,17 @@ export function useSshAuthTypes(): { return requireHost().useSshAuthTypes(); } +/** + * The visible contributions to a slot another plugin owns, with their + * metadata (id, titleKey, component). For an owner that needs to build a + * catalog from what was contributed rather than just render it in place, e.g. + * host-metrics listing manager cards plugins added to "host-metrics.managers" + * next to its own. Same permission and `when` filtering as ComponentSlot. + */ +export function useSlotContributions(slotId: string): SlotContribution[] { + return requireHost().useSlotContributions(slotId); +} + /** * Runs an action another plugin registered, from anywhere in a plugin's code. * Resolves undefined when nobody registered it, so an optional dependency diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index fe8d31317..efac467ce 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -111,6 +111,7 @@ const offline = () => })), loaded: true, }), + useSlotContributions: () => [], }; afterEach(() => { diff --git a/plugins/host-metrics/locales/en.json b/plugins/host-metrics/locales/en.json index df5cee0ff..072c67710 100644 --- a/plugins/host-metrics/locales/en.json +++ b/plugins/host-metrics/locales/en.json @@ -168,7 +168,6 @@ "firewallPersist": "Persist rules", "firewallPersisted": "Firewall rules persisted", "wireguard": "WireGuard", - "tailscale": "Tailscale", "wgNotInstalled": "WireGuard is not installed on this host", "wgNoInterfaces": "No WireGuard interfaces configured", "wgInterfaceUp": "Bring up", @@ -183,23 +182,7 @@ "wgAllowedIPs": "Allowed IPs", "wgLastHandshake": "Last handshake", "wgHandshakeNever": "Never", - "wgTransfer": "Transfer", - "tsNotInstalled": "Tailscale is not installed on this host", - "tsRunning": "Running", - "tsStopped": "Stopped", - "tsEnable": "Connect", - "tsDisable": "Disconnect", - "tsEnabling": "Connecting to Tailscale...", - "tsDisabling": "Disconnecting from Tailscale...", - "tsEnabled": "Tailscale connected", - "tsDisabled": "Tailscale disconnected", - "tsIPs": "Tailscale IPs", - "tsPeers": "Peers", - "tsOnline": "Online", - "tsOffline": "Offline", - "tsExitNode": "Exit node", - "tsExitNodeActive": "Exit node active", - "tsHostname": "Hostname" + "wgTransfer": "Transfer" } }, "metricsHistory": { diff --git a/plugins/host-metrics/manifest.json b/plugins/host-metrics/manifest.json index e976e3464..ff6d6e1d5 100644 --- a/plugins/host-metrics/manifest.json +++ b/plugins/host-metrics/manifest.json @@ -49,7 +49,13 @@ "component": "retention" } ] - } + }, + "actionSlots": [ + { + "id": "host-metrics.managers", + "accepts": ["component"] + } + ] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/host-metrics/src/backend/managers/index.ts b/plugins/host-metrics/src/backend/managers/index.ts index f9147e1d5..6edf360ed 100644 --- a/plugins/host-metrics/src/backend/managers/index.ts +++ b/plugins/host-metrics/src/backend/managers/index.ts @@ -13,7 +13,6 @@ import { registerUserRoutes } from "./users.js"; import { registerHealthRoutes } from "./health.js"; import { registerLogRoutes } from "./logs.js"; import { registerWireGuardRoutes } from "./wireguard.js"; -import { registerTailscaleRoutes } from "./tailscale.js"; /** * Registers every Host Metrics manager route under the `/host-metrics/managers` @@ -61,5 +60,4 @@ export function registerManagerRoutes( registerHealthRoutes(app, deps); registerLogRoutes(app, deps); registerWireGuardRoutes(app, deps); - registerTailscaleRoutes(app, deps); } diff --git a/plugins/host-metrics/src/backend/managers/tailscale.ts b/plugins/host-metrics/src/backend/managers/tailscale.ts deleted file mode 100644 index 0104c1e22..000000000 --- a/plugins/host-metrics/src/backend/managers/tailscale.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Express } from "express"; -import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; -import { managerHandler, ManagerInputError } from "./route-helpers.js"; -import type { ManagerRoutesDeps } from "./types.js"; -import { isValidTailscaleAction } from "../../../../../src/backend/hosts/metrics-shared/validation.js"; - -export interface TailscalePeer { - hostname: string; - tailscaleIPs: string[]; - online: boolean; - isExitNode: boolean; -} - -export interface TailscaleData { - installed: boolean; - running: boolean; - tailscaleIPs: string[]; - hostname: string | null; - peers: TailscalePeer[]; - exitNodeInUse: boolean; -} - -const PROBE_CMD = [ - "command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0", - "tailscale status --json 2>/dev/null", -].join("; "); - -export function parseTailscaleData(output: string): TailscaleData { - const notInstalled: TailscaleData = { - installed: false, - running: false, - tailscaleIPs: [], - hostname: null, - peers: [], - exitNodeInUse: false, - }; - - if (output.includes("ts_installed=0")) return notInstalled; - - // Strip the installation probe line to get the raw JSON - const lines = output.split("\n"); - const jsonLines = lines.filter( - (l) => !l.startsWith("ts_installed=") && l.trim() !== "", - ); - const jsonStr = jsonLines.join("\n"); - - try { - const parsed = JSON.parse(jsonStr) as { - BackendState?: string; - Self?: { HostName?: string; TailscaleIPs?: string[] }; - Peer?: Record< - string, - { - HostName?: string; - TailscaleIPs?: string[]; - Online?: boolean; - ExitNode?: boolean; - } - >; - CurrentExitNode?: string; - }; - - const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map( - (p) => ({ - hostname: p.HostName ?? "", - tailscaleIPs: p.TailscaleIPs ?? [], - online: p.Online ?? false, - isExitNode: p.ExitNode ?? false, - }), - ); - - return { - installed: true, - running: parsed.BackendState === "Running", - tailscaleIPs: parsed.Self?.TailscaleIPs ?? [], - hostname: parsed.Self?.HostName ?? null, - peers, - exitNodeInUse: - typeof parsed.CurrentExitNode === "string" && - parsed.CurrentExitNode !== "", - }; - } catch { - return { - installed: true, - running: false, - tailscaleIPs: [], - hostname: null, - peers: [], - exitNodeInUse: false, - }; - } -} - -export function registerTailscaleRoutes( - app: Express, - { validateHostId, runOnHost }: ManagerRoutesDeps, -): void { - /** - * @openapi - * /host-metrics/managers/tailscale/{id}: - * get: - * summary: Get Tailscale status and IPs - * tags: - * - Host Metrics - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Tailscale installation status, running state, IPs, and peer count. - */ - app.get( - "/host-metrics/managers/tailscale/:id", - validateHostId, - managerHandler(runOnHost, "connect", "tailscale_read", async (client) => { - const { stdout } = await execCommand(client, PROBE_CMD, 15000); - return parseTailscaleData(stdout); - }), - ); - - /** - * @openapi - * /host-metrics/managers/tailscale/{id}/action: - * post: - * summary: Connect or disconnect Tailscale - * tags: - * - Host Metrics - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * action: - * type: string - * enum: [up, down] - * responses: - * 200: - * description: Action result. - */ - app.post( - "/host-metrics/managers/tailscale/:id/action", - validateHostId, - managerHandler( - runOnHost, - "connect", - "tailscale_action", - async (client, host, req) => { - const { action } = req.body as { action: unknown }; - if (!isValidTailscaleAction(action)) { - throw new ManagerInputError("Invalid action, must be 'up' or 'down'"); - } - const result = await execElevated( - client, - `tailscale ${action}`, - host.sudoPassword, - { forceSudo: false, timeoutMs: 30000 }, - ); - return { - success: result.code === 0, - output: (result.stdout + result.stderr).trim(), - }; - }, - ), - ); -} diff --git a/plugins/host-metrics/src/frontend/HostMetricsTab.tsx b/plugins/host-metrics/src/frontend/HostMetricsTab.tsx index f97a91442..af531f0db 100644 --- a/plugins/host-metrics/src/frontend/HostMetricsTab.tsx +++ b/plugins/host-metrics/src/frontend/HostMetricsTab.tsx @@ -32,7 +32,10 @@ function hasSnippetInputs(content: string): boolean { } import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { useTabsSafe } from "@/shell/TabContext.tsx"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { + useTranslation, + useSlotContributions, +} from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { type StatsConfig, @@ -61,6 +64,7 @@ import { CARD_DEFINITIONS, IMPLEMENTED_CARD_IDS, getCardDefinition, + definitionsFromSlot, defaultColSpanFor, defaultHeightFor, type MetricCardHistories, @@ -177,38 +181,57 @@ function HostMetricsInner({ ); }, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]); + // Manager cards other plugins contribute (tailscale's, say), merged in next + // to the ones this plugin ships. A contribution for a slot that never + // arrives (its plugin is off) just never appears here. + const managerContributions = useSlotContributions("host-metrics.managers"); + const pluginManagerDefs = React.useMemo( + () => definitionsFromSlot(managerContributions), + [managerContributions], + ); + // Only render/keep cards that are implemented (metric cards in Phase A). const visibleSlots = React.useMemo( - () => effectiveLayout.slots.filter((s) => getCardDefinition(s.id)), - [effectiveLayout.slots], + () => + effectiveLayout.slots.filter((s) => + getCardDefinition(s.id, pluginManagerDefs), + ), + [effectiveLayout.slots, pluginManagerDefs], ); const cardCatalog: GridCardCatalogEntry[] = React.useMemo( - () => - IMPLEMENTED_CARD_IDS.map((id) => ({ + () => [ + ...IMPLEMENTED_CARD_IDS.map((id) => ({ id, label: t(CARD_DEFINITIONS[id].labelKey), defaultColSpan: defaultColSpanFor(id), defaultHeight: defaultHeightFor(id), })), - [t], + ...Object.values(pluginManagerDefs).map((def) => ({ + id: def.id, + label: t(def.labelKey), + defaultColSpan: defaultColSpanFor(def.id), + defaultHeight: defaultHeightFor(def.id), + })), + ], + [t, pluginManagerDefs], ); const cardLabel = React.useCallback( (id: string) => { - const def = getCardDefinition(id); + const def = getCardDefinition(id, pluginManagerDefs); return def ? t(def.labelKey) : id; }, - [t], + [t, pluginManagerDefs], ); const renderCard = React.useCallback( (id: string) => { - const def = getCardDefinition(id); + const def = getCardDefinition(id, pluginManagerDefs); if (!def) return null; return def.render({ metrics, histories, hostId }); }, - [metrics, histories, hostId], + [metrics, histories, hostId, pluginManagerDefs], ); React.useEffect(() => { diff --git a/plugins/host-metrics/src/frontend/cards/index.tsx b/plugins/host-metrics/src/frontend/cards/index.tsx index be30234df..4b4c3aef7 100644 --- a/plugins/host-metrics/src/frontend/cards/index.tsx +++ b/plugins/host-metrics/src/frontend/cards/index.tsx @@ -33,7 +33,6 @@ import { DiskBreakdownCard, } from "./managers/SimpleManagerCards"; import { WireGuardManagerCard } from "./managers/WireGuardManagerCard"; -import { TailscaleManagerCard } from "./managers/TailscaleManagerCard"; export interface MetricCardHistories { cpu: number[]; @@ -208,20 +207,46 @@ export const CARD_DEFINITIONS: Record = { "hostMetrics.managers.wireguard", WireGuardManagerCard, ), - tailscale_manager: managerCard( - "tailscale_manager", - "hostMetrics.managers.tailscale", - TailscaleManagerCard, - ), }; -/** All card ids implemented and available in the Add tray. */ +/** + * Manager cards other plugins contribute to "host-metrics.managers" (a + * component slot), keyed by contribution actionId. Built once per render from + * useSlotContributions and merged with CARD_DEFINITIONS below, so a plugin + * like tailscale can add its own manager card without host-metrics knowing it + * exists. + */ +export function definitionsFromSlot( + contributions: Array<{ + actionId: string; + titleKey: string; + component?: ComponentType>; + }>, +): Record { + const out: Record = {}; + for (const contribution of contributions) { + if (!contribution.component) continue; + const Comp = contribution.component; + out[contribution.actionId] = { + id: contribution.actionId as HostMetricsCardId, + labelKey: contribution.titleKey, + kind: "manager", + render: ({ hostId }) => , + }; + } + return out; +} + +/** All card ids built into this plugin and available in the Add tray. */ export const IMPLEMENTED_CARD_IDS = Object.keys( CARD_DEFINITIONS, ) as HostMetricsCardId[]; -export function getCardDefinition(id: string): CardDefinition | undefined { - return CARD_DEFINITIONS[id]; +export function getCardDefinition( + id: string, + extra?: Record, +): CardDefinition | undefined { + return CARD_DEFINITIONS[id] ?? extra?.[id]; } export { defaultColSpanFor, defaultHeightFor }; diff --git a/plugins/host-metrics/src/frontend/index.tsx b/plugins/host-metrics/src/frontend/index.tsx index 61a5c813b..13090eea8 100644 --- a/plugins/host-metrics/src/frontend/index.tsx +++ b/plugins/host-metrics/src/frontend/index.tsx @@ -158,4 +158,9 @@ export function activate(app: TermixApp): void { descriptionKey: "onboarding.feature_metrics_desc", icon: Activity as ComponentType<{ className?: string }>, }); + + app.declareActionSlot({ + id: "host-metrics.managers", + accepts: ["component"], + }); } diff --git a/plugins/tailscale/locales/en.json b/plugins/tailscale/locales/en.json index 2dda22191..1461911f3 100644 --- a/plugins/tailscale/locales/en.json +++ b/plugins/tailscale/locales/en.json @@ -50,5 +50,24 @@ "tailscaleCheckOpenBrowser": "Open Browser to Authenticate", "tailscaleCheckWaiting": "Waiting for Tailscale authentication...", "tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again." + }, + "manager": { + "title": "Tailscale", + "refresh": "Refresh", + "retry": "Retry", + "sudoHint": "Set a sudo password for this host to allow elevated actions.", + "noData": "No data available", + "filter": "Filter...", + "tsNotInstalled": "Tailscale is not installed on this host", + "tsRunning": "Running", + "tsStopped": "Stopped", + "tsEnable": "Connect", + "tsDisable": "Disconnect", + "tsEnabling": "Connecting to Tailscale...", + "tsDisabling": "Disconnecting from Tailscale...", + "tsEnabled": "Tailscale connected", + "tsDisabled": "Tailscale disconnected", + "tsExitNode": "Exit node", + "tsExitNodeActive": "Exit node active" } } diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json index 28935015c..f04cb871f 100644 --- a/plugins/tailscale/manifest.json +++ b/plugins/tailscale/manifest.json @@ -18,8 +18,14 @@ "settings:read-core", "network:outbound", "network:serve", + "auth:provide", + "ssh:connect", + "credentials:use", "ui:surface" ], + "optionalDependencies": { + "host-metrics": "^1.0.0" + }, "contributes": { "panels": [ { @@ -32,9 +38,13 @@ { "name": "devices.view", "titleKey": "permissions.devices.view.title", - "descriptionKey": "permissions.devices.view.description" + "descriptionKey": "permissions.devices.view.description", + "defaultRoles": ["admin"] } ], + "auth": { + "sshAuthTypes": ["tailscale"] + }, "settings": { "admin": [ { diff --git a/plugins/tailscale/src/backend/host-metrics-manager.ts b/plugins/tailscale/src/backend/host-metrics-manager.ts new file mode 100644 index 000000000..8f82c39fc --- /dev/null +++ b/plugins/tailscale/src/backend/host-metrics-manager.ts @@ -0,0 +1,269 @@ +import type { Router } from "express"; +import type { Request, Response } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { isValidTailscaleAction } from "../../../../src/backend/hosts/metrics-shared/validation.js"; +import { withSshConnection } from "./ssh.js"; + +export interface TailscalePeer { + hostname: string; + tailscaleIPs: string[]; + online: boolean; + isExitNode: boolean; +} + +export interface TailscaleData { + installed: boolean; + running: boolean; + tailscaleIPs: string[]; + hostname: string | null; + peers: TailscalePeer[]; + exitNodeInUse: boolean; +} + +const PROBE_CMD = [ + "command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0", + "tailscale status --json 2>/dev/null", +].join("; "); + +export function parseTailscaleData(output: string): TailscaleData { + const notInstalled: TailscaleData = { + installed: false, + running: false, + tailscaleIPs: [], + hostname: null, + peers: [], + exitNodeInUse: false, + }; + + if (output.includes("ts_installed=0")) return notInstalled; + + const lines = output.split("\n"); + const jsonLines = lines.filter( + (l) => !l.startsWith("ts_installed=") && l.trim() !== "", + ); + const jsonStr = jsonLines.join("\n"); + + try { + const parsed = JSON.parse(jsonStr) as { + BackendState?: string; + Self?: { HostName?: string; TailscaleIPs?: string[] }; + Peer?: Record< + string, + { + HostName?: string; + TailscaleIPs?: string[]; + Online?: boolean; + ExitNode?: boolean; + } + >; + CurrentExitNode?: string; + }; + + const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map( + (p) => ({ + hostname: p.HostName ?? "", + tailscaleIPs: p.TailscaleIPs ?? [], + online: p.Online ?? false, + isExitNode: p.ExitNode ?? false, + }), + ); + + return { + installed: true, + running: parsed.BackendState === "Running", + tailscaleIPs: parsed.Self?.TailscaleIPs ?? [], + hostname: parsed.Self?.HostName ?? null, + peers, + exitNodeInUse: + typeof parsed.CurrentExitNode === "string" && + parsed.CurrentExitNode !== "", + }; + } catch { + return { + installed: true, + running: false, + tailscaleIPs: [], + hostname: null, + peers: [], + exitNodeInUse: false, + }; + } +} + +class AccessDeniedError extends Error { + constructor(message = "No access to this host") { + super(message); + this.name = "AccessDeniedError"; + } +} + +class ManagerInputError extends Error { + constructor(message: string) { + super(message); + this.name = "ManagerInputError"; + } +} + +interface ManagerHost { + id: number; + userId: string; + sudoPassword?: string; +} + +async function resolveManagerHost( + hostId: number, + userId: string, +): Promise { + const access = await PermissionManager.getInstance().canAccessHost( + userId, + hostId, + "connect", + ); + if (!access.hasAccess) throw new AccessDeniedError(); + + if (DataCrypto.getUserDataKey(userId) === null) { + throw new AccessDeniedError(); + } + const host = await resolveHostById(hostId, userId); + if (!host) throw new AccessDeniedError("Host not found"); + + let sudoPassword = (host as { sudoPassword?: string }).sudoPassword; + const ownerId = (host as { userId?: string }).userId; + if (ownerId && ownerId !== userId) { + const ownerHost = await resolveHostById(hostId, ownerId); + sudoPassword = (ownerHost as { sudoPassword?: string } | null) + ?.sudoPassword; + } + + return { id: hostId, userId, sudoPassword }; +} + +function managerErrorResponse( + res: Response, + operation: string, + error: unknown, +) { + if (error instanceof ManagerInputError) { + return res.status(400).json({ error: error.message }); + } + if (error instanceof AccessDeniedError) { + return res.status(403).json({ error: error.message }); + } + const message = error instanceof Error ? error.message : String(error); + return res.status(500).json({ error: message || `${operation} failed` }); +} + +export function registerTailscaleHostMetricsManager( + router: Router, + ctx: PluginContext, +): void { + /** + * @openapi + * /plugin-api/tailscale/host-metrics-manager/{id}: + * get: + * summary: Get Tailscale status and IPs for a host + * tags: + * - Tailscale + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Tailscale installation status, running state, IPs, and peer count. + */ + router.get( + "/host-metrics-manager/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const hostId = parseInt(String(req.params.id), 10); + try { + const host = await resolveManagerHost(hostId, userId); + const data = await withSshConnection( + host.id, + { pool: "tailscale", purpose: "metrics" }, + async (client) => { + const { stdout } = await execCommand(client, PROBE_CMD, 15000); + return parseTailscaleData(stdout); + }, + ); + res.json(data); + } catch (error) { + ctx.log.error( + "Failed to read Tailscale status", + error instanceof Error ? error : undefined, + ); + managerErrorResponse(res, "tailscale_read", error); + } + }, + ); + + /** + * @openapi + * /plugin-api/tailscale/host-metrics-manager/{id}/action: + * post: + * summary: Connect or disconnect Tailscale on a host + * tags: + * - Tailscale + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * enum: [up, down] + * responses: + * 200: + * description: Action result. + */ + router.post( + "/host-metrics-manager/:id/action", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const hostId = parseInt(String(req.params.id), 10); + try { + const host = await resolveManagerHost(hostId, userId); + const { action } = req.body as { action: unknown }; + if (!isValidTailscaleAction(action)) { + throw new ManagerInputError("Invalid action, must be 'up' or 'down'"); + } + const result = await withSshConnection( + host.id, + { pool: "tailscale", purpose: "metrics" }, + (client) => + execElevated(client, `tailscale ${action}`, host.sudoPassword, { + forceSudo: false, + timeoutMs: 30000, + }), + ); + res.json({ + success: result.code === 0, + output: (result.stdout + result.stderr).trim(), + }); + } catch (error) { + ctx.log.error( + "Failed to run Tailscale action", + error instanceof Error ? error : undefined, + ); + managerErrorResponse(res, "tailscale_action", error); + } + }, + ); +} diff --git a/plugins/tailscale/src/backend/index.ts b/plugins/tailscale/src/backend/index.ts index 707d178f2..91ad452b5 100644 --- a/plugins/tailscale/src/backend/index.ts +++ b/plugins/tailscale/src/backend/index.ts @@ -1,11 +1,20 @@ +import type { Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startTailscaleService, stopTailscaleService } from "./routes.js"; +import { registerTailscaleSshAuth } from "./ssh-auth-provider.js"; +import { registerTailscaleHostMetricsManager } from "./host-metrics-manager.js"; +import { setPluginSsh } from "./ssh.js"; export async function activate(ctx: PluginContext) { - startTailscaleService(ctx.http.router(), ctx); + const router = ctx.http.router(); + startTailscaleService(router, ctx); + registerTailscaleHostMetricsManager(router, ctx); + setPluginSsh(ctx.ssh); + registerTailscaleSshAuth(ctx); ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale"); } export async function deactivate() { + setPluginSsh(null); stopTailscaleService(); } diff --git a/plugins/tailscale/src/backend/routes.ts b/plugins/tailscale/src/backend/routes.ts index ea5664897..c2611abec 100644 --- a/plugins/tailscale/src/backend/routes.ts +++ b/plugins/tailscale/src/backend/routes.ts @@ -1,11 +1,4 @@ -// Was src/backend/database/routes/tailscale-routes.ts, moved here and -// rewritten to register itself via the shared /tailscale dispatcher -// (src/backend/database/routes/tailscale-dispatch.ts) instead of taking a -// direct reference to the app instance, the same way fleets does. - import { Router } from "express"; -import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; -import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; import { apiLogger } from "../../../../src/backend/utils/logger.js"; import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js"; import type { PluginContext } from "@termix/plugin-sdk/backend"; @@ -31,145 +24,127 @@ interface TailscaleAPIDevice { const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2"; -export const router = Router(); - -/** - * Set by activate(). The API key and base URL are this plugin's own settings - * now, read through ctx rather than out of the core settings table. - */ -let context: PluginContext | null = null; - -const authManager = AuthManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); - -/** - * Listing the tailnet uses the admin-stored API key, so it needs more than a - * session. Writing the key is already admin-only and reading it back is - * masked, but using it to enumerate every device was open to any logged-in - * user, which handed out an internal network inventory. - */ -const requireDeviceAccess = PermissionManager.getInstance().requirePermission( - "tailscale.devices.view", -); - function normalizeApiBase(raw: string | null): string { const trimmed = (raw ?? "").trim().replace(/\/+$/, ""); return trimmed || DEFAULT_TAILSCALE_API_BASE; } /** - * @openapi - * /tailscale/devices: - * get: - * summary: List Tailscale devices - * description: > - * Returns the devices in the configured tailnet using the stored admin - * API key. Requires the tailscale.devices.view permission, because the - * response is a full inventory of the tailnet. - * tags: - * - Tailscale - * responses: - * 200: - * description: List of tailnet devices. - * 403: - * description: The caller lacks tailscale.devices.view. - * content: - * application/json: - * schema: - * type: object - * properties: - * devices: - * type: array - * items: - * type: object - * 500: - * description: Failed to fetch Tailscale devices. + * Set by activate(). Dropped on deactivate so a request arriving mid-teardown + * cannot read settings through a context the runtime has already torn down. */ -router.get( - "/devices", - authenticateJWT, - requireDeviceAccess, - async (_req, res) => { - try { - if (!context) { - return res - .status(503) - .json({ error: "Plugin is not running", devices: [] }); - } - const apiKey = (await context.settings.get("apiKey")) ?? ""; - if (!apiKey) { - return res.json({ devices: [], hasApiKey: false }); - } - const apiBase = normalizeApiBase( - (await context.settings.get("apiBaseUrl")) ?? null, - ); +let context: PluginContext | null = null; - const url = `${apiBase}/tailnet/-/devices?fields=all`; - const response = await fetchWithProxy(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - "User-Agent": "Termix/1.0", - }, - }); - - if (!response.ok) { - apiLogger.warn("Tailscale API returned non-OK status", { - operation: "tailscale_devices", - status: response.status, - }); - if (response.status === 401 || response.status === 403) { - return res.status(401).json({ - error: "Invalid Tailscale API key", - devices: [], - hasApiKey: true, - }); - } - return res.status(502).json({ - error: "Tailscale API error", - devices: [], - hasApiKey: true, - }); - } - - const data = (await response.json()) as { devices: TailscaleAPIDevice[] }; - - const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({ - id: d.id, - name: d.name, - hostname: d.hostname, - addresses: d.addresses ?? [], - os: d.os, - lastSeen: d.lastSeen, - })); - - res.json({ devices, hasApiKey: true }); - } catch (err) { - apiLogger.error("Failed to fetch Tailscale devices", err, { - operation: "tailscale_devices", - }); - res.status(500).json({ - error: "Failed to fetch Tailscale devices", - devices: [], - hasApiKey: true, - }); - } - }, -); - -/** Called from activate(). Mounts this router at /tailscale via the shared - * dispatcher. */ +/** Called from activate(). Mounts the plugin's routes on ctx.http.router(). */ export function startTailscaleService( mountOn: Router, ctx: PluginContext, ): void { context = ctx; - mountOn.use(router); + + /** + * @openapi + * /plugin-api/tailscale/devices: + * get: + * summary: List Tailscale devices + * description: > + * Returns the devices in the configured tailnet using the stored admin + * API key. Requires the tailscale.devices.view permission, because the + * response is a full inventory of the tailnet. + * tags: + * - Tailscale + * responses: + * 200: + * description: List of tailnet devices. + * 403: + * description: The caller lacks tailscale.devices.view. + * content: + * application/json: + * schema: + * type: object + * properties: + * devices: + * type: array + * items: + * type: object + * 500: + * description: Failed to fetch Tailscale devices. + */ + mountOn.get( + "/devices", + ctx.rbac.require("devices.view") as never, + async (_req, res) => { + try { + if (!context) { + return res + .status(503) + .json({ error: "Plugin is not running", devices: [] }); + } + const apiKey = (await context.settings.get("apiKey")) ?? ""; + if (!apiKey) { + return res.json({ devices: [], hasApiKey: false }); + } + const apiBase = normalizeApiBase( + (await context.settings.get("apiBaseUrl")) ?? null, + ); + + const url = `${apiBase}/tailnet/-/devices?fields=all`; + const response = await fetchWithProxy(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + "User-Agent": "Termix/1.0", + }, + }); + + if (!response.ok) { + apiLogger.warn("Tailscale API returned non-OK status", { + operation: "tailscale_devices", + status: response.status, + }); + if (response.status === 401 || response.status === 403) { + return res.status(401).json({ + error: "Invalid Tailscale API key", + devices: [], + hasApiKey: true, + }); + } + return res.status(502).json({ + error: "Tailscale API error", + devices: [], + hasApiKey: true, + }); + } + + const data = (await response.json()) as { + devices: TailscaleAPIDevice[]; + }; + + const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({ + id: d.id, + name: d.name, + hostname: d.hostname, + addresses: d.addresses ?? [], + os: d.os, + lastSeen: d.lastSeen, + })); + + res.json({ devices, hasApiKey: true }); + } catch (err) { + apiLogger.error("Failed to fetch Tailscale devices", err, { + operation: "tailscale_devices", + }); + res.status(500).json({ + error: "Failed to fetch Tailscale devices", + devices: [], + hasApiKey: true, + }); + } + }, + ); } -/** Called from deactivate(). /tailscale/* falls back to 404 until +/** Called from deactivate(). /plugin-api/tailscale/* falls back to 404 until * reactivated. */ export function stopTailscaleService(): void { - // Dropped so a request arriving mid-teardown cannot read settings through a - // context the runtime has already torn down. context = null; } diff --git a/plugins/tailscale/src/backend/ssh-auth-provider.ts b/plugins/tailscale/src/backend/ssh-auth-provider.ts new file mode 100644 index 000000000..97f595606 --- /dev/null +++ b/plugins/tailscale/src/backend/ssh-auth-provider.ts @@ -0,0 +1,74 @@ +import type { + PluginContext, + PluginSshAuthProvider, +} from "@termix/plugin-sdk/backend"; +import { + isTailscaleCheckCompleteBanner, + parseTailscaleCheckBanner, + TAILSCALE_CHECK_TIMEOUT_MS, +} from "./tailscale-check.js"; + +const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; + +export const tailscaleSshAuthProvider: PluginSshAuthProvider = { + type: "tailscale", + labelKey: "hosts.filterAuthTailscale", + connectOptions: (_host, purpose) => + purpose === "terminal" + ? { + tryKeyboard: false, + readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, + // The socket sits idle while a check-mode login is pending. + timeout: TAILSCALE_CHECK_TIMEOUT_MS, + } + : { tryKeyboard: false }, + prepare: async (_config, _host, env) => { + env.log("info", "Using Tailscale SSH"); + return { status: "ready" }; + }, + onBanner: (banner) => { + const check = parseTailscaleCheckBanner(banner); + if (check) { + return { + action: "hold", + timeoutMs: TAILSCALE_CHECK_TIMEOUT_MS, + message: `Tailscale SSH requires an additional check. Waiting for browser authentication at ${check.url}`, + details: { url: check.url, message: check.message }, + }; + } + if (isTailscaleCheckCompleteBanner(banner)) { + return { action: "release" }; + } + return undefined; + }, + onAuthFailed: (host, _env, context) => { + const failed = + context.methodNotAvailable || + AUTH_FAILED_PATTERN.test(context.error.message); + if (!failed) return; + + // Tailscale documents the "+password" suffix for clients that mishandle a + // successful "none" reply. The password value is ignored. + if (context.retries === 0 && context.canRetry) { + return { + status: "retry", + message: "Retrying Tailscale SSH in forced password mode", + patch: { + username: `${host.username}+password`, + password: "termix", + tryKeyboard: false, + }, + }; + } + + return { + status: "error", + code: "failed", + message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`, + }; + }, +}; + +export function registerTailscaleSshAuth(ctx: PluginContext): void { + ctx.auth.registerSshAuthProvider(tailscaleSshAuthProvider); +} diff --git a/plugins/tailscale/src/backend/ssh.ts b/plugins/tailscale/src/backend/ssh.ts new file mode 100644 index 000000000..c887ed0a7 --- /dev/null +++ b/plugins/tailscale/src/backend/ssh.ts @@ -0,0 +1,27 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} diff --git a/src/backend/hosts/tailscale-check.ts b/plugins/tailscale/src/backend/tailscale-check.ts similarity index 90% rename from src/backend/hosts/tailscale-check.ts rename to plugins/tailscale/src/backend/tailscale-check.ts index 5df26b85f..45a68f4f4 100644 --- a/src/backend/hosts/tailscale-check.ts +++ b/plugins/tailscale/src/backend/tailscale-check.ts @@ -8,6 +8,9 @@ const TAILSCALE_CHECK_URL = /https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/; const CHECK_COMPLETE = /authentication checked/i; +/** How long a Tailscale check-mode login may sit waiting for approval. */ +export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; + export interface TailscaleCheckBanner { url: string; message: string; diff --git a/plugins/host-metrics/src/frontend/cards/managers/TailscaleManagerCard.tsx b/plugins/tailscale/src/frontend/TailscaleManagerCard.tsx similarity index 77% rename from plugins/host-metrics/src/frontend/cards/managers/TailscaleManagerCard.tsx rename to plugins/tailscale/src/frontend/TailscaleManagerCard.tsx index 63bece758..a02382bf1 100644 --- a/plugins/host-metrics/src/frontend/cards/managers/TailscaleManagerCard.tsx +++ b/plugins/tailscale/src/frontend/TailscaleManagerCard.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Power } from "lucide-react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; -import { useManagerData, useManagerAction } from "./useManagerData"; -import { ManagerCardShell } from "./ManagerCardShell"; -import { ManagerSearch } from "./ManagerToolbar"; +import { useTailscaleData, useTailscaleAction } from "./useTailscaleManager"; +import { + TailscaleManagerShell, + TailscaleManagerSearch, +} from "./TailscaleManagerShell"; interface TailscalePeer { hostname: string; @@ -23,30 +25,23 @@ interface TailscaleData { export function TailscaleManagerCard({ hostId }: { hostId: number | null }) { const { t } = useTranslation(); - const { data, loading, error, refresh } = useManagerData( - hostId, - "tailscale", - ); - const { busy, run } = useManagerAction(hostId); + const { data, loading, error, refresh } = + useTailscaleData(hostId); + const { busy, run } = useTailscaleAction(hostId); const [query, setQuery] = useState(""); const handleToggle = async () => { if (!data) return; const action = data.running ? "down" : "up"; - await run( - "tailscale", - { action }, - { - action: "action", - loadingMsg: data.running - ? t("hostMetrics.managers.tsDisabling") - : t("hostMetrics.managers.tsEnabling"), - successMsg: data.running - ? t("hostMetrics.managers.tsDisabled") - : t("hostMetrics.managers.tsEnabled"), - onDone: refresh, - }, - ); + await run(action, { + loadingMsg: data.running + ? t("manager.tsDisabling") + : t("manager.tsEnabling"), + successMsg: data.running + ? t("manager.tsDisabled") + : t("manager.tsEnabled"), + onDone: refresh, + }); }; const filteredPeers = (data?.peers ?? []).filter( @@ -59,14 +54,14 @@ export function TailscaleManagerCard({ hostId }: { hostId: number | null }) { const isNotInstalled = data && !data.installed; return ( - } loading={loading} error={error} onRefresh={refresh} empty={!!isNotInstalled} - emptyMessage={t("hostMetrics.managers.tsNotInstalled")} + emptyMessage={t("manager.tsNotInstalled")} > {data && (
@@ -79,8 +74,8 @@ export function TailscaleManagerCard({ hostId }: { hostId: number | null }) {
{data.running - ? t("hostMetrics.managers.tsRunning") - : t("hostMetrics.managers.tsStopped")} + ? t("manager.tsRunning") + : t("manager.tsStopped")} {data.hostname && ( @@ -102,9 +97,7 @@ export function TailscaleManagerCard({ hostId }: { hostId: number | null }) { onClick={handleToggle} disabled={busy || !data.installed} title={ - data.running - ? t("hostMetrics.managers.tsDisable") - : t("hostMetrics.managers.tsEnable") + data.running ? t("manager.tsDisable") : t("manager.tsEnable") } className={`flex size-6 items-center justify-center transition-colors disabled:opacity-40 ${ data.running @@ -121,14 +114,14 @@ export function TailscaleManagerCard({ hostId }: { hostId: number | null }) { {data.exitNodeInUse && (
- {t("hostMetrics.managers.tsExitNodeActive")} + {t("manager.tsExitNodeActive")}
)} {/* Peers */} {(data.peers?.length ?? 0) > 5 && ( - {peer.isExitNode && ( - {t("hostMetrics.managers.tsExitNode")} + {t("manager.tsExitNode")} )}
))}
)} -
+ ); } diff --git a/plugins/tailscale/src/frontend/TailscaleManagerShell.tsx b/plugins/tailscale/src/frontend/TailscaleManagerShell.tsx new file mode 100644 index 000000000..049008e88 --- /dev/null +++ b/plugins/tailscale/src/frontend/TailscaleManagerShell.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from "react"; +import { RefreshCw, AlertTriangle, Search } from "lucide-react"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { Button } from "@/components/button"; +import { MetricCard } from "@/components/metric-card"; +import type { ManagerError } from "./useTailscaleManager"; + +/** + * Local copy of host-metrics' ManagerCardShell, kept in step with it by eye. + * A shared component belongs in the SDK once a second plugin needs it. + */ +export function TailscaleManagerShell({ + title, + icon, + loading, + error, + onRefresh, + empty, + emptyMessage, + children, + headerExtra, +}: { + title: string; + icon: ReactNode; + loading: boolean; + error: ManagerError | null; + onRefresh: () => void; + empty?: boolean; + emptyMessage?: string; + children: ReactNode; + headerExtra?: ReactNode; +}) { + const { t } = useTranslation(); + return ( + + {headerExtra} + + + } + > + {error ? ( +
+ + {error.message} + {error.code === "SUDO_REQUIRED" && ( + + {t("manager.sudoHint")} + + )} + +
+ ) : empty ? ( +
+ {emptyMessage ?? t("manager.noData")} +
+ ) : ( + children + )} +
+ ); +} + +/** Local copy of host-metrics' ManagerSearch. */ +export function TailscaleManagerSearch({ + value, + onChange, + count, +}: { + value: string; + onChange: (v: string) => void; + count?: number; +}) { + const { t } = useTranslation(); + return ( +
+
+ + onChange(e.target.value)} + placeholder={t("manager.filter")} + className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring" + /> +
+ {count != null && ( + + {count} + + )} +
+ ); +} diff --git a/plugins/tailscale/src/frontend/index.tsx b/plugins/tailscale/src/frontend/index.tsx index 790a7cd32..b952c0c23 100644 --- a/plugins/tailscale/src/frontend/index.tsx +++ b/plugins/tailscale/src/frontend/index.tsx @@ -6,6 +6,7 @@ import { TailscaleDevicesPanel } from "./TailscaleDevicesPanel"; import { TailscaleDevicesStatus } from "./TailscaleDevicesStatus"; import { TailscaleAuthEditor } from "./TailscaleAuthEditor"; import { TailscaleCheckOverlay } from "./TailscaleCheckOverlay"; +import { TailscaleManagerCard } from "./TailscaleManagerCard"; function Panel({ shell }: PanelProps) { return ( @@ -49,4 +50,14 @@ export function activate(app: TermixApp): void { Record >, }); + + // The Host Metrics manager card, present only when host-metrics is running. + app.registerSlotContribution("host-metrics.managers", { + actionId: "tailscale_manager", + titleKey: "manager.title", + kind: "component", + component: TailscaleManagerCard as unknown as ComponentType< + Record + >, + }); } diff --git a/plugins/tailscale/src/frontend/useTailscaleManager.ts b/plugins/tailscale/src/frontend/useTailscaleManager.ts new file mode 100644 index 000000000..ed91def18 --- /dev/null +++ b/plugins/tailscale/src/frontend/useTailscaleManager.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { usePluginApi } from "@termix/plugin-sdk/frontend"; + +export interface ManagerError { + message: string; + code?: string; +} + +function extractError(err: unknown): ManagerError { + const e = err as { + response?: { data?: { error?: string; code?: string } }; + message?: string; + }; + return { + message: e?.response?.data?.error || e?.message || "Request failed", + code: e?.response?.data?.code, + }; +} + +/** Fetch the Tailscale manager card's status on mount + manual refresh. */ +export function useTailscaleData(hostId: number | null) { + const api = usePluginApi(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (hostId == null) return; + setLoading(true); + setError(null); + try { + const res = await api.get(`/host-metrics-manager/${hostId}`); + setData(res.data); + } catch (err) { + setError(extractError(err)); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hostId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { data, loading, error, refresh }; +} + +interface ActionResult { + success: boolean; + output?: string; +} + +/** Runs the Tailscale manager card's up/down action with toast feedback. */ +export function useTailscaleAction(hostId: number | null) { + const api = usePluginApi(); + const [busy, setBusy] = useState(false); + + const run = useCallback( + async ( + action: "up" | "down", + opts: { + loadingMsg?: string; + successMsg?: string; + failMsg?: string; + onDone?: () => void; + } = {}, + ): Promise => { + if (hostId == null) return null; + const id = `tailscale-manager-${hostId}`; + setBusy(true); + if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id }); + try { + const res = await api.post( + `/host-metrics-manager/${hostId}/action`, + { action }, + ); + if (res.data.success) { + if (opts.successMsg) + toast.success(opts.successMsg, { + id, + description: res.data.output?.slice(-200), + }); + else toast.dismiss(id); + opts.onDone?.(); + } else { + toast.error(opts.failMsg ?? res.data.output ?? "Action failed", { + id, + description: res.data.output?.slice(-200), + }); + } + return res.data; + } catch (err) { + toast.error(extractError(err).message, { id }); + return null; + } finally { + setBusy(false); + } + }, + [hostId], + ); + + return { busy, run }; +} diff --git a/plugins/tailscale/tests/backend/host-metrics-manager.test.ts b/plugins/tailscale/tests/backend/host-metrics-manager.test.ts new file mode 100644 index 000000000..ea4210f8e --- /dev/null +++ b/plugins/tailscale/tests/backend/host-metrics-manager.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { parseTailscaleData } from "../../src/backend/host-metrics-manager.js"; + +describe("parseTailscaleData", () => { + it("reports not installed when the probe finds no binary", () => { + const result = parseTailscaleData("ts_installed=0\n"); + expect(result.installed).toBe(false); + expect(result.running).toBe(false); + }); + + it("parses a running tailscale status", () => { + const status = JSON.stringify({ + BackendState: "Running", + Self: { HostName: "my-server", TailscaleIPs: ["100.1.2.3"] }, + Peer: { + abc: { + HostName: "peer-1", + TailscaleIPs: ["100.1.2.4"], + Online: true, + ExitNode: false, + }, + }, + CurrentExitNode: "", + }); + const result = parseTailscaleData(`ts_installed=1\n${status}`); + + expect(result.installed).toBe(true); + expect(result.running).toBe(true); + expect(result.hostname).toBe("my-server"); + expect(result.tailscaleIPs).toEqual(["100.1.2.3"]); + expect(result.peers).toHaveLength(1); + expect(result.peers[0]).toMatchObject({ + hostname: "peer-1", + online: true, + }); + expect(result.exitNodeInUse).toBe(false); + }); + + it("reports installed but not running on malformed JSON", () => { + const result = parseTailscaleData("ts_installed=1\nnot json"); + expect(result.installed).toBe(true); + expect(result.running).toBe(false); + }); + + it("detects an active exit node", () => { + const status = JSON.stringify({ + BackendState: "Running", + Self: {}, + Peer: {}, + CurrentExitNode: "abc123", + }); + const result = parseTailscaleData(`ts_installed=1\n${status}`); + expect(result.exitNodeInUse).toBe(true); + }); +}); diff --git a/plugins/tailscale/tests/backend/permissions.test.ts b/plugins/tailscale/tests/backend/permissions.test.ts index 51a271fa9..d697d3c5b 100644 --- a/plugins/tailscale/tests/backend/permissions.test.ts +++ b/plugins/tailscale/tests/backend/permissions.test.ts @@ -43,12 +43,12 @@ describe("tailscale permissions", () => { } }); - // No wildcard covered tailscale in SYSTEM_ROLE_DEFAULTS, so nothing is - // granted by default and an admin has to opt in. - it("grants nothing by default", () => { - for (const permission of manifest.manifest?.contributes?.permissions ?? - []) { - expect(permission.defaultRoles).toBeUndefined(); - } + // The device list is a full tailnet inventory, so only admins get it by + // default; everyone else needs an explicit grant. + it("grants devices.view to admins by default", () => { + const devicesView = manifest.manifest?.contributes?.permissions?.find( + (permission) => permission.name === "devices.view", + ); + expect(devicesView?.defaultRoles).toEqual(["admin"]); }); }); diff --git a/plugins/tailscale/tests/backend/ssh-auth-provider.test.ts b/plugins/tailscale/tests/backend/ssh-auth-provider.test.ts new file mode 100644 index 000000000..e9f2fce0f --- /dev/null +++ b/plugins/tailscale/tests/backend/ssh-auth-provider.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { createFakeContext } from "@termix/plugin-sdk/testing"; +import { registerTailscaleSshAuth } from "../../src/backend/ssh-auth-provider.js"; +import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../src/backend/tailscale-check.js"; + +function register() { + const { ctx, auth } = createFakeContext({ pluginId: "tailscale" }); + registerTailscaleSshAuth(ctx); + return auth.sshAuthProviders.find( + (provider) => provider.type === "tailscale", + )!; +} + +describe("tailscale SSH auth provider", () => { + it("registers as type tailscale", () => { + expect(register()).toBeTruthy(); + }); + + it("turns off keyboard-interactive, with a long timeout for the terminal", () => { + const provider = register(); + const terminal = provider.connectOptions?.( + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + "terminal", + ); + expect(terminal).toMatchObject({ + tryKeyboard: false, + readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, + timeout: TAILSCALE_CHECK_TIMEOUT_MS, + }); + + const fleet = provider.connectOptions?.( + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + "fleet", + ); + expect(fleet).toEqual({ tryKeyboard: false }); + }); + + it("prepare always reports ready", async () => { + const provider = register(); + const outcome = await provider.prepare( + {}, + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + ); + expect(outcome).toEqual({ status: "ready" }); + }); + + describe("onBanner", () => { + it("holds the connection when the banner carries a check URL", () => { + const provider = register(); + const decision = provider.onBanner?.( + "# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n", + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + ); + expect(decision).toMatchObject({ + action: "hold", + details: { url: "https://login.tailscale.com/a/abc123" }, + }); + }); + + it("releases the hold on the completion banner", () => { + const provider = register(); + const decision = provider.onBanner?.( + "Authentication checked with Tailscale SSH.", + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + ); + expect(decision).toEqual({ action: "release" }); + }); + + it("ignores an ordinary MOTD banner", () => { + const provider = register(); + const decision = provider.onBanner?.( + "Welcome to Ubuntu 24.04 LTS\n", + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + ); + expect(decision).toBeUndefined(); + }); + }); + + describe("onAuthFailed", () => { + it("retries once in forced password mode", () => { + const provider = register(); + const outcome = provider.onAuthFailed?.( + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + { + error: new Error("All configured authentication methods failed"), + retries: 0, + canRetry: true, + methodNotAvailable: false, + }, + ); + expect(outcome).toMatchObject({ + status: "retry", + patch: { username: "root+password" }, + }); + }); + + it("fails after a retry has already happened", () => { + const provider = register(); + const outcome = provider.onAuthFailed?.( + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + { + error: new Error("All configured authentication methods failed"), + retries: 1, + canRetry: true, + methodNotAvailable: false, + }, + ); + expect(outcome).toMatchObject({ status: "error", code: "failed" }); + }); + + it("does nothing for an unrelated failure", () => { + const provider = register(); + const outcome = provider.onAuthFailed?.( + { id: 1, ip: "100.1.1.1", port: 22, username: "root" }, + { + client: {}, + userId: "u1", + hostId: 1, + purpose: "terminal", + interactive: true, + log: () => {}, + }, + { + error: new Error("connection reset"), + retries: 0, + canRetry: true, + methodNotAvailable: false, + }, + ); + expect(outcome).toBeUndefined(); + }); + }); +}); diff --git a/plugins/tailscale/tests/backend/tailscale-check.test.ts b/plugins/tailscale/tests/backend/tailscale-check.test.ts index 445f697c0..218b802f3 100644 --- a/plugins/tailscale/tests/backend/tailscale-check.test.ts +++ b/plugins/tailscale/tests/backend/tailscale-check.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseTailscaleCheckBanner, isTailscaleCheckCompleteBanner, -} from "../../../../src/backend/hosts/tailscale-check.js"; +} from "../../src/backend/tailscale-check.js"; describe("parseTailscaleCheckBanner", () => { it("extracts the login URL from a real check-mode banner", () => { diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index 991033f1e..fa2899ed2 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -99,6 +99,7 @@ "plugins/tailscale/src/frontend/TailscaleCheckOverlay.tsx", "plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx", "plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx", + "plugins/tailscale/src/frontend/TailscaleManagerShell.tsx", "plugins/tailscale/src/frontend/index.tsx", "plugins/tailscale/src/frontend/tailscale-api.ts", "plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx", @@ -149,7 +150,6 @@ "plugins/host-metrics/src/backend/managers/services.ts", "plugins/host-metrics/src/backend/managers/simple-reads.ts", "plugins/host-metrics/src/backend/managers/ssl.ts", - "plugins/host-metrics/src/backend/managers/tailscale.ts", "plugins/host-metrics/src/backend/managers/types.ts", "plugins/host-metrics/src/backend/managers/users.ts", "plugins/host-metrics/src/backend/managers/wireguard.ts", @@ -175,6 +175,7 @@ "plugins/remote-desktop/src/backend/routes.ts", "plugins/remote-desktop/src/backend/token-service.ts", "plugins/ssh-terminal/src/backend/index.ts", + "plugins/tailscale/src/backend/host-metrics-manager.ts", "plugins/tailscale/src/backend/routes.ts", "plugins/web-endpoint/src/backend/routes.ts" ], diff --git a/src/backend/auth/legacy-providers.ts b/src/backend/auth/legacy-providers.ts index d23f225d2..60f8fe1aa 100644 --- a/src/backend/auth/legacy-providers.ts +++ b/src/backend/auth/legacy-providers.ts @@ -31,9 +31,6 @@ const VAULT_REQUIRED_MESSAGE = const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; -/** How long a Tailscale check-mode login may sit waiting for approval. */ -export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; - function issuedCertificateProvider( type: "opkssh" | "stepca", labelKey: string, @@ -207,51 +204,6 @@ const vaultProvider: SshAuthProvider = { }, }; -const tailscaleProvider: SshAuthProvider = { - type: "tailscale", - pluginId: "core", - labelKey: "hosts.filterAuthTailscale", - connectOptions: (_host, purpose) => - purpose === "terminal" - ? { - tryKeyboard: false, - readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, - // The socket sits idle while a check-mode login is pending. - timeout: TAILSCALE_CHECK_TIMEOUT_MS, - } - : { tryKeyboard: false }, - prepare: async (_config, _host, env) => { - env.log("info", "Using Tailscale SSH"); - return { status: "ready" }; - }, - onAuthFailed: (host, _env, context) => { - const failed = - context.methodNotAvailable || - AUTH_FAILED_PATTERN.test(context.error.message); - if (!failed) return; - - // Tailscale documents the "+password" suffix for clients that mishandle a - // successful "none" reply. The password value is ignored. - if (context.retries === 0 && context.canRetry) { - return { - status: "retry", - message: "Retrying Tailscale SSH in forced password mode", - patch: { - username: `${host.username}+password`, - password: "termix", - tryKeyboard: false, - }, - }; - } - - return { - status: "error", - code: "failed", - message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`, - }; - }, -}; - const WARPGATE_PATTERN = /warpgate\s+authentication/i; let sshRegistered = false; @@ -267,7 +219,6 @@ export function registerLegacySshAuthProviders(): void { issuedCertificateProvider("stepca", "hosts.filterAuthStepca"), ); registerSshAuthProvider(vaultProvider); - registerSshAuthProvider(tailscaleProvider); // Warpgate is a flag on a host, not an auth type: it shows up as a // keyboard-interactive round with a sign-in URL. diff --git a/src/backend/hosts/connect/types.ts b/src/backend/hosts/connect/types.ts index 39360d694..ec06cb178 100644 --- a/src/backend/hosts/connect/types.ts +++ b/src/backend/hosts/connect/types.ts @@ -193,8 +193,36 @@ export interface SshAuthProvider { env: SshAuthEnv, context: SshAuthFailureContext, ) => SshAuthOutcome | undefined; + /** + * Claims an ssh2 "banner" event during the handshake, for a provider whose + * server holds the connection open pending an out-of-band step (Tailscale + * SSH check mode). A transport that can show this to a person sends + * `_check_required` / `_check_completed` over its socket; + * `details` is merged into that message. Returning nothing leaves the + * banner unhandled. + */ + onBanner?: ( + banner: string, + host: SshConnectHost, + env: SshAuthEnv, + ) => SshBannerDecision | undefined; } +export type SshBannerDecision = + | { + /** Hold the connect timeout open and wait for the out-of-band step. */ + action: "hold"; + /** How long to wait before failing the connection. */ + timeoutMs: number; + message: string; + details?: Record; + } + | { + /** The out-of-band step finished; resume the normal connect timeout. */ + action: "release"; + details?: Record; + }; + export interface KeyboardInteractivePrompt { prompt: string; echo?: boolean; diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 4f49c58e6..73096b3b3 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -37,10 +37,6 @@ import type { SshConnectHost, } from "../connect/types.js"; import { JumpHostChainError } from "../jump-host-chain.js"; -import { - parseTailscaleCheckBanner, - isTailscaleCheckCompleteBanner, -} from "../tailscale-check.js"; import { sessionManager, isMessageAllowedForParticipant, @@ -130,10 +126,6 @@ interface TOTPResponseData { const authManager = AuthManager.getInstance(); -// Tailscale holds a check-mode connection open for up to 30 minutes while the -// user completes the browser login, so match that rather than timing out first. -const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; - const userConnections = new Map>(); // Re-exported so the ssh-terminal plugin can publish it on the service @@ -1632,11 +1624,12 @@ wss.on("connection", async (ws: WebSocket, req) => { } }; - // Reassigned when Tailscale check mode starts, so the short connect timeout - // does not tear down a connection the server is deliberately holding open. + // Reassigned when an auth provider's onBanner holds the connection open + // (Tailscale SSH check mode), so the short connect timeout does not tear + // down a connection the server is deliberately waiting on. let connectionTimeout = setTimeout(onConnectionTimeout, 120000); - let tailscaleCheckPending = false; + let bannerHoldPending = false; let authRetries = 0; let isAuthRetrying = false; let connectProvider: SshAuthProvider | null = null; @@ -1912,41 +1905,44 @@ wss.on("connection", async (ws: WebSocket, req) => { } sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); - // Tailscale SSH check mode delivers its re-auth URL as an auth banner and then - // blocks for up to 30 minutes while the user logs in via the browser. + // A provider can hold the handshake open pending an out-of-band step + // (Tailscale SSH check mode delivers its re-auth URL as an auth banner, + // then blocks while the user logs in via the browser). sshConn.on("banner", (banner: string) => { - const check = parseTailscaleCheckBanner(banner); - if (check) { - tailscaleCheckPending = true; + if (!connectProvider?.onBanner || !connectTarget || !connectEnv) return; + const decision = connectProvider.onBanner( + banner, + connectTarget, + connectEnv, + ); + if (!decision) return; + const authType = connectProvider.type; + + if (decision.action === "hold") { + bannerHoldPending = true; clearTimeout(connectionTimeout); - connectionTimeout = setTimeout( - onConnectionTimeout, - TAILSCALE_CHECK_TIMEOUT_MS, - ); - - sendLog( - "auth", - "info", - `Tailscale SSH requires an additional check. Waiting for browser authentication at ${check.url}`, - ); - + connectionTimeout = setTimeout(onConnectionTimeout, decision.timeoutMs); + sendLog("auth", "info", decision.message); ws.send( JSON.stringify({ - type: "tailscale_check_required", + type: `${authType}_check_required`, hostId: id, - url: check.url, - message: check.message, + ...decision.details, }), ); return; } - if (tailscaleCheckPending && isTailscaleCheckCompleteBanner(banner)) { - tailscaleCheckPending = false; - sendLog("auth", "info", "Tailscale SSH check completed"); + if (bannerHoldPending) { + bannerHoldPending = false; + sendLog("auth", "info", `${authType} check completed`); ws.send( - JSON.stringify({ type: "tailscale_check_completed", hostId: id }), + JSON.stringify({ + type: `${authType}_check_completed`, + hostId: id, + ...decision.details, + }), ); } }); @@ -1955,10 +1951,13 @@ wss.on("connection", async (ws: WebSocket, req) => { clearOnlineStatus ??= hostSessionStatus.register(serverHostId); clearTimeout(connectionTimeout); isAuthRetrying = false; - if (tailscaleCheckPending) { - tailscaleCheckPending = false; + if (bannerHoldPending) { + bannerHoldPending = false; ws.send( - JSON.stringify({ type: "tailscale_check_completed", hostId: id }), + JSON.stringify({ + type: `${connectProvider?.type}_check_completed`, + hostId: id, + }), ); } sshLogger.success("SSH connection established", { @@ -2518,7 +2517,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ? connectProvider.onAuthFailed(connectTarget, connectEnv, { error: err, retries: authRetries, - canRetry: !connectConfig.sock && !tailscaleCheckPending, + canRetry: !connectConfig.sock && !bannerHoldPending, methodNotAvailable: authMethodNotAvailable, }) : undefined; diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index 596ce47fd..6fc37bab1 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -324,6 +324,11 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { ) as never; }, }; + if (provider.onBanner) { + const onBanner = provider.onBanner; + wrapped.onBanner = (banner, host, env) => + onBanner(banner, host as PluginSshHost, env as never) as never; + } ensureCoreSshAuthProviders(); const disposers = [registerSshAuthProvider(wrapped)]; if (provider.onKeyboardInteractive) { diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index 634ac0adb..a5d39e12e 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -94,7 +94,6 @@ import { setSshAuthTypeOwnerSource, } from "../../../hosts/connect/auth-provider-registry.js"; import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js"; -import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-providers.js"; import type { SshConnectHost } from "../../../hosts/connect/types.js"; const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private; @@ -325,48 +324,8 @@ describe("buildConnectConfig per auth type", () => { expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3); }); - it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => { - const terminal = await build(host({ authType: "tailscale" })); - expect(terminal.config).toMatchObject({ - tryKeyboard: false, - readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, - timeout: TAILSCALE_CHECK_TIMEOUT_MS, - }); - - const fleet = await build(host({ authType: "tailscale" }), "fleet"); - expect(fleet.config.tryKeyboard).toBe(false); - expect(fleet.config.readyTimeout).toBe(30000); - }); - - it("tailscale retries once with +password, then reports the failure", async () => { - ensureCoreSshAuthProviders(); - const provider = getSshAuthProvider("tailscale")!; - const env = {} as never; - const failure = new Error("All configured authentication methods failed"); - - const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, { - error: failure, - retries: 0, - canRetry: true, - methodNotAvailable: false, - }); - expect(first).toMatchObject({ - status: "retry", - patch: { username: "root+password", tryKeyboard: false }, - }); - - const second = provider.onAuthFailed!( - host({ authType: "tailscale" }), - env, - { - error: failure, - retries: 1, - canRetry: true, - methodNotAvailable: false, - }, - ); - expect(second).toMatchObject({ status: "error" }); - }); + // Tailscale is a plugin-registered SSH auth provider now: see + // plugins/tailscale/tests/backend/ssh-auth-provider.test.ts. it("a type nobody provides names the plugin that would", async () => { setSshAuthTypeOwnerSource(() => [ diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 7c1ec2332..dc120c4f9 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -25,6 +25,7 @@ import { knownPluginIds } from "./plugin-store"; import { tabsApi, useShellHosts } from "./shell-bridge"; import { invokeAction } from "@/shell/action-registry"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; +import { useActionSlot } from "@/hooks/use-action-slot"; /** * Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK @@ -270,6 +271,8 @@ export const pluginHostBridge: PluginHostBridge = { })), }; }, + + useSlotContributions: (slotId) => useActionSlot(slotId), }; /** Installs the bridge. Idempotent. */