From 8742c5d381201838b04a6e8fe75f1e5bab5ba5cc Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 22 Sep 2026 10:19:40 -0500 Subject: [PATCH] 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. --- plugins/tailscale/backend/index.mjs | 69 ++++++++ plugins/tailscale/backend/routes.ts | 142 ++++++++++++++++ .../frontend/AdminTailscaleSection.tsx | 93 ++++++++++ .../frontend}/TailscaleCheckDialog.tsx | 0 .../frontend/TailscaleDevicesPanel.tsx | 160 ++++++++++++++++++ plugins/tailscale/frontend/index.mjs | 40 +++++ plugins/tailscale/frontend/tailscale-api.ts | 71 ++++++++ plugins/tailscale/manifest.json | 20 +++ src/backend/database/database.ts | 4 +- .../database/routes/tailscale-dispatch.ts | 47 +++++ .../database/routes/tailscale-routes.ts | 128 -------------- src/backend/plugins/first-party.ts | 7 + src/backend/tests/plugins/first-party.test.ts | 20 ++- src/types/ui-types.ts | 3 +- src/ui/AppShell.tsx | 14 ++ src/ui/api/settings-api.ts | 70 +------- src/ui/features/terminal/Terminal.tsx | 2 +- src/ui/locales/en.json | 2 + src/ui/sidebar/AdminSettingsPanel.tsx | 12 +- src/ui/sidebar/AdminSettingsSections.tsx | 60 ------- src/ui/sidebar/AppRail.tsx | 1 + src/ui/sidebar/HostEditor.tsx | 3 +- src/ui/sidebar/rail-items.ts | 7 + .../tailscale/AdminTailscaleSection.test.tsx | 62 +++++++ .../tailscale/TailscaleDevicesPanel.test.tsx | 76 +++++++++ .../tailscale/tailscale-api.test.ts} | 2 +- src/ui/tests/sidebar/rail-items.test.ts | 1 + 27 files changed, 850 insertions(+), 266 deletions(-) create mode 100644 plugins/tailscale/backend/index.mjs create mode 100644 plugins/tailscale/backend/routes.ts create mode 100644 plugins/tailscale/frontend/AdminTailscaleSection.tsx rename {src/ui/ssh/dialogs => plugins/tailscale/frontend}/TailscaleCheckDialog.tsx (100%) create mode 100644 plugins/tailscale/frontend/TailscaleDevicesPanel.tsx create mode 100644 plugins/tailscale/frontend/index.mjs create mode 100644 plugins/tailscale/frontend/tailscale-api.ts create mode 100644 plugins/tailscale/manifest.json create mode 100644 src/backend/database/routes/tailscale-dispatch.ts delete mode 100644 src/backend/database/routes/tailscale-routes.ts create mode 100644 src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx create mode 100644 src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx rename src/ui/tests/{api/tailscale-settings-api.test.ts => plugins/tailscale/tailscale-api.test.ts} (93%) diff --git a/plugins/tailscale/backend/index.mjs b/plugins/tailscale/backend/index.mjs new file mode 100644 index 000000000..7155515fa --- /dev/null +++ b/plugins/tailscale/backend/index.mjs @@ -0,0 +1,69 @@ +/** + * Tailscale - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker for the same + * reason fleets and proxmox do (see src/backend/plugins/first-party.ts): it + * does not own a live transport of its own (no WebSocket server, no + * dedicated port), so it registers its Express router with the + * always-mounted /tailscale dispatcher in + * src/backend/database/routes/tailscale-dispatch.ts instead of getting its + * own port. + * + * routes.ts is what used to be src/backend/database/routes/tailscale-routes.ts, + * moved here and rewritten from a direct app.use() call into + * startTailscaleService()/stopTailscaleService(), called from + * activate()/deactivate() below. + * + * tailscale-check.ts (SSH re-auth banner parsing) stays in core, at + * src/backend/hosts/tailscale-check.ts, and is NOT part of this plugin. + * It is imported directly by src/backend/hosts/terminal/index.ts (owned by + * ssh-terminal), which type-checks under tsconfig.node.json's rootDir of + * src/ -- a plugin .ts file physically outside that root cannot be a static + * import target for core code (see scripts/copy-bundled-plugins.cjs's own + * comment: plugin backends import core, never the reverse, anywhere in this + * codebase). Moving the file into plugins/tailscale/backend/ would require + * either widening tsconfig.node.json's rootDir for every other plugin's + * dist path-rewriting, or switching terminal/index.ts to a dynamic import + * inside its banner handler -- both bigger changes than this plugin + * warrants. The banner/ready wiring and retry state already live in + * ssh-terminal's connection handler regardless, so this stays exactly where + * that coupling already lives. + * + * The /users/tailscale-settings GET/PATCH routes stay in core + * (src/backend/database/routes/user-settings-routes.ts) -- they are general + * user-settings infrastructure, not Tailscale-specific plumbing, and this + * plugin's frontend calls them the same way core code does. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing + * imports this plugin directly (the loader loads in-process plugins with a + * plain dynamic import, no tsx loader involved), so the only layout that + * needs to resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startTailscaleService(); + ctx.log.info("Tailscale router registered at /tailscale"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopTailscaleService(); + routesModule = null; + } +} diff --git a/plugins/tailscale/backend/routes.ts b/plugins/tailscale/backend/routes.ts new file mode 100644 index 000000000..f70c0aed4 --- /dev/null +++ b/plugins/tailscale/backend/routes.ts @@ -0,0 +1,142 @@ +// 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 { apiLogger } from "../../../src/backend/utils/logger.js"; +import { fetchWithProxy } from "../../../src/backend/utils/proxy-agent.js"; +import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; +import { + registerTailscaleRouter, + unregisterTailscaleRouter, +} from "../../../src/backend/database/routes/tailscale-dispatch.js"; + +interface TailscaleDevice { + id: string; + name: string; + hostname: string; + addresses: string[]; + os: string; + lastSeen: string; +} + +interface TailscaleAPIDevice { + id: string; + name: string; + hostname: string; + addresses: string[]; + os: string; + lastSeen: string; + nodeId?: string; +} + +const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2"; + +export const router = Router(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +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 list of devices in the configured tailnet using the stored API key. + * tags: + * - Tailscale + * responses: + * 200: + * description: List of tailnet devices. + * content: + * application/json: + * schema: + * type: object + * properties: + * devices: + * type: array + * items: + * type: object + * 500: + * description: Failed to fetch Tailscale devices. + */ +router.get("/devices", authenticateJWT, async (_req, res) => { + try { + const settingsRepo = createCurrentSettingsRepository(); + const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? ""; + if (!apiKey) { + return res.json({ devices: [], hasApiKey: false }); + } + const apiBase = normalizeApiBase( + await settingsRepo.get("tailscale_api_base_url"), + ); + + 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. */ +export function startTailscaleService(): void { + registerTailscaleRouter(router); +} + +/** Called from deactivate(). /tailscale/* falls back to 404 until + * reactivated. */ +export function stopTailscaleService(): void { + unregisterTailscaleRouter(); +} diff --git a/plugins/tailscale/frontend/AdminTailscaleSection.tsx b/plugins/tailscale/frontend/AdminTailscaleSection.tsx new file mode 100644 index 000000000..5aa9f40e0 --- /dev/null +++ b/plugins/tailscale/frontend/AdminTailscaleSection.tsx @@ -0,0 +1,93 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { Network } from "lucide-react"; +import { AccordionSection } from "@/sidebar/AdminSettingsShared"; + +// Was inline in AdminGeneralSettingsSection (src/ui/sidebar/AdminSettingsSections.tsx), +// moved here into its own accordion section as part of the Tailscale plugin. +// The backing routes (GET/PATCH /users/tailscale-settings) stay in core - +// they are general user-settings infrastructure this plugin calls, not +// something it owns. + +type AdminTailscaleSectionProps = { + open: boolean; + onToggle: () => void; + tailscaleApiKey: string; + setTailscaleApiKey: Dispatch>; + tailscaleApiBaseUrl: string; + setTailscaleApiBaseUrl: Dispatch>; + handleSaveTailscaleApiKey: () => void; +}; + +export function AdminTailscaleSection({ + open, + onToggle, + tailscaleApiKey, + setTailscaleApiKey, + tailscaleApiBaseUrl, + setTailscaleApiBaseUrl, + handleSaveTailscaleApiKey, +}: AdminTailscaleSectionProps) { + const { t } = useTranslation(); + + return ( + } + open={open} + onToggle={onToggle} + > +
+
+ + {t("admin.tailscaleApiKey")} + + + {t("admin.tailscaleApiKeyDescription")}{" "} + + {t("admin.tailscaleApiKeyDocsLink")} + + +
+
+ setTailscaleApiKey(e.target.value)} + placeholder="tskey-api-... / hskey-api-..." + className="text-sm" + /> + +
+
+ + + {t("admin.tailscaleApiBaseUrlDescription")} + + setTailscaleApiBaseUrl(e.target.value)} + placeholder="https://api.tailscale.com/api/v2" + className="text-sm" + /> +
+
+
+ ); +} diff --git a/src/ui/ssh/dialogs/TailscaleCheckDialog.tsx b/plugins/tailscale/frontend/TailscaleCheckDialog.tsx similarity index 100% rename from src/ui/ssh/dialogs/TailscaleCheckDialog.tsx rename to plugins/tailscale/frontend/TailscaleCheckDialog.tsx diff --git a/plugins/tailscale/frontend/TailscaleDevicesPanel.tsx b/plugins/tailscale/frontend/TailscaleDevicesPanel.tsx new file mode 100644 index 000000000..389db1310 --- /dev/null +++ b/plugins/tailscale/frontend/TailscaleDevicesPanel.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2, RefreshCw, Terminal } from "lucide-react"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import type { Host } from "@/types/ui-types"; +import { getTailscaleDevices } from "./tailscale-api"; +import { createQuickConnectHost } from "../../../src/ui/sidebar/quick-connect-host"; + +interface TailscaleDevice { + id: string; + name: string; + hostname: string; + addresses: string[]; + os: string; + lastSeen: string; +} + +interface TailscaleDevicesPanelProps { + onConnect: (host: Host, type: "terminal") => void; +} + +function deviceIp(device: TailscaleDevice): string { + return ( + device.addresses.find((a) => a.startsWith("100.")) ?? + device.addresses[0] ?? + "" + ); +} + +export function TailscaleDevicesPanel({ + onConnect, +}: TailscaleDevicesPanelProps) { + const { t } = useTranslation(); + const [devices, setDevices] = useState([]); + const [hasApiKey, setHasApiKey] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [usernames, setUsernames] = useState>({}); + + function load() { + setLoading(true); + setError(false); + getTailscaleDevices() + .then((res) => { + setDevices(res.devices); + setHasApiKey(res.hasApiKey); + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + } + + useEffect(load, []); + + function connect(device: TailscaleDevice) { + const ip = deviceIp(device); + if (!ip) return; + const username = usernames[device.id]?.trim() || "root"; + const host = createQuickConnectHost({ + ip, + port: 22, + username, + authType: "tailscale", + }); + onConnect(host, "terminal"); + } + + return ( +
+
+ + {t("nav.tailscale")} + + +
+ +
+ {loading && ( +
+ + {t("hosts.tailscaleLoadingDevices")} +
+ )} + + {!loading && !hasApiKey && ( +

+ {t("hosts.tailscaleNoApiKey")} +

+ )} + + {!loading && hasApiKey && error && ( +

+ {t("hosts.tailscaleDeviceLoadFailed")} +

+ )} + + {!loading && hasApiKey && !error && devices.length === 0 && ( +

+ {t("hosts.tailscaleNoDevices")} +

+ )} + + {!loading && + hasApiKey && + !error && + devices.map((device) => { + const ip = deviceIp(device); + return ( +
+
+ + {device.hostname || device.name} + + + {ip} {device.os ? `ยท ${device.os}` : ""} + +
+ + setUsernames((prev) => ({ + ...prev, + [device.id]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") connect(device); + }} + className="h-7 text-xs" + /> + +
+ ); + })} +
+
+ ); +} + +export default TailscaleDevicesPanel; diff --git a/plugins/tailscale/frontend/index.mjs b/plugins/tailscale/frontend/index.mjs new file mode 100644 index 000000000..f2c443370 --- /dev/null +++ b/plugins/tailscale/frontend/index.mjs @@ -0,0 +1,40 @@ +/** + * Frontend half of the tailscale plugin. + * + * TailscaleDevicesPanel is a left-sidebar railView panel (opened from the + * rail, not a Tab), the same shape as FleetsPanel/QuickConnectPanel. None of + * those go through registerRailItem/registerTabComponent either -- per + * src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is + * rendered directly by AppShell.tsx rather than registered at runtime. + * TailscaleDevicesPanel is reached today through a direct relative import in + * AppShell.tsx (../../plugins/tailscale/frontend/TailscaleDevicesPanel), not + * through register() below. + * + * register()/unregister() still follow the same shape every other plugin's + * frontend entry does, so this is ready the moment a real frontend plugin + * loader exists -- see plugins/ssh-terminal/frontend/index.mjs and + * src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright + * "there is no plugin loader yet". + * + * TailscaleCheckDialog is not registered here at all: it is rendered + * directly by src/ui/features/terminal/Terminal.tsx (owned by ssh-terminal), + * driven by raw WS message types on the terminal socket. There is no + * dialog-registration extension point for that today, so this plugin ships + * the component as a plain module ssh-terminal imports by path. + */ + +export const id = "tailscale"; +export const railViewId = "tailscale"; + +export async function register({ registerRailItem, icons }) { + registerRailItem?.({ + id: railViewId, + icon: icons.Radar, + labelKey: "nav.tailscale", + separatorAfter: true, + }); +} + +export async function unregister({ unregisterRailItem }) { + unregisterRailItem?.(railViewId); +} diff --git a/plugins/tailscale/frontend/tailscale-api.ts b/plugins/tailscale/frontend/tailscale-api.ts new file mode 100644 index 000000000..6221f479f --- /dev/null +++ b/plugins/tailscale/frontend/tailscale-api.ts @@ -0,0 +1,71 @@ +// Was part of src/ui/api/settings-api.ts, moved here with the rest of the +// Tailscale plugin. The backing routes stay in place: /users/tailscale-settings +// is general user-settings infrastructure in core, and /tailscale/devices is +// this plugin's own route (plugins/tailscale/backend/routes.ts). + +import axios from "axios"; +import { authApi, handleApiError } from "@/main-axios"; + +export async function getTailscaleSettings(): Promise<{ + apiKey: string; + hasApiKey: boolean; + apiBaseUrl: string; +}> { + try { + const response = await authApi.get("/users/tailscale-settings"); + return response.data; + } catch (error) { + handleApiError(error, "fetch Tailscale settings"); + } +} + +export async function updateTailscaleSettings( + apiKey: string, + apiBaseUrl?: string, +): Promise<{ hasApiKey: boolean }> { + try { + const response = await authApi.patch("/users/tailscale-settings", { + apiKey, + apiBaseUrl, + }); + return response.data; + } catch (error) { + handleApiError(error, "update Tailscale settings"); + } +} + +export async function getTailscaleDevices(): Promise<{ + devices: Array<{ + id: string; + name: string; + hostname: string; + addresses: string[]; + os: string; + lastSeen: string; + }>; + hasApiKey: boolean; + error?: string; +}> { + try { + const response = await authApi.get("/tailscale/devices"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const data = error.response?.data; + if ( + data && + typeof data === "object" && + "hasApiKey" in data && + typeof data.hasApiKey === "boolean" + ) { + return data as { + devices: []; + hasApiKey: boolean; + error?: string; + }; + } + } + handleApiError(error, "fetch Tailscale devices"); + throw error; + } +} diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json new file mode 100644 index 000000000..d313df463 --- /dev/null +++ b/plugins/tailscale/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "tailscale", + "name": "Tailscale", + "version": "1.0.0", + "description": "Browse and connect to tailnet devices, manage the Tailscale API key, and show the Tailscale SSH re-authentication dialog.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Networking", + "icon": "Network", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "sidecars": [] +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 139a78c70..a1002d4da 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -28,7 +28,7 @@ import uiPreferencesRoutes from "./routes/ui-preferences.js"; import proxmoxDispatch from "./routes/proxmox-dispatch.js"; import termixIdRoutes from "./routes/termix-id.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; -import { registerTailscaleRoutes } from "./routes/tailscale-routes.js"; +import tailscaleDispatch from "./routes/tailscale-dispatch.js"; import vaultRoutes from "./routes/vault.js"; import secretSourceRoutes from "./routes/secret-sources.js"; import notificationChannelsRoutes from "./routes/notification-channels-routes.js"; @@ -1771,7 +1771,7 @@ app.use("/ui-preferences", uiPreferencesRoutes); app.use("/proxmox", proxmoxDispatch); app.use("/termix-id", termixIdRoutes); registerAuditLogRoutes(app, authenticateJWT); -registerTailscaleRoutes(app, authenticateJWT); +app.use("/tailscale", tailscaleDispatch); app.use("/vault", vaultRoutes); app.use("/secret-sources", secretSourceRoutes); app.use("/automations", automationsDispatch); diff --git a/src/backend/database/routes/tailscale-dispatch.ts b/src/backend/database/routes/tailscale-dispatch.ts new file mode 100644 index 000000000..8915750d3 --- /dev/null +++ b/src/backend/database/routes/tailscale-dispatch.ts @@ -0,0 +1,47 @@ +// Mounted at /tailscale. Tailscale ships as a first-party in-process plugin +// (plugins/tailscale) rather than a route file imported directly here, so +// this module exists to keep /tailscale/* working at its original path +// without handing the plugin a reference to the shared app instance. The +// plugin runtime registers its router via registerTailscaleRouter when it +// activates, and clears it on deactivate, so a disabled plugin falls back to +// the 404 below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Tailscale plugin's own), and every +// route in that router already applies authenticateJWT itself, the same as +// every other router mounted alongside it here. /plugin-api gates at the +// mount point because it forwards to arbitrary third-party plugin code that +// cannot be assumed to gate itself; that reasoning does not apply to a single +// first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerTailscaleRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered Tailscale plugin router", { + operation: "tailscale_dispatch_register", + }); +} + +export function unregisterTailscaleRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered Tailscale plugin router", { + operation: "tailscale_dispatch_register", + }); +} + +export default function tailscaleDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Tailscale is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/database/routes/tailscale-routes.ts b/src/backend/database/routes/tailscale-routes.ts deleted file mode 100644 index 1340cc280..000000000 --- a/src/backend/database/routes/tailscale-routes.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - Router, - type RequestHandler, - type Router as ExpressRouter, -} from "express"; -import { apiLogger } from "../../utils/logger.js"; -import { fetchWithProxy } from "../../utils/proxy-agent.js"; -import { createCurrentSettingsRepository } from "../repositories/factory.js"; - -interface TailscaleDevice { - id: string; - name: string; - hostname: string; - addresses: string[]; - os: string; - lastSeen: string; -} - -interface TailscaleAPIDevice { - id: string; - name: string; - hostname: string; - addresses: string[]; - os: string; - lastSeen: string; - nodeId?: string; -} - -const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2"; - -const router = Router(); - -function normalizeApiBase(raw: string | null): string { - const trimmed = (raw ?? "").trim().replace(/\/+$/, ""); - return trimmed || DEFAULT_TAILSCALE_API_BASE; -} - -export function registerTailscaleRoutes( - app: ExpressRouter, - authenticateJWT: RequestHandler, -): void { - /** - * @openapi - * /tailscale/devices: - * get: - * summary: List Tailscale devices - * description: Returns the list of devices in the configured tailnet using the stored API key. - * tags: - * - Tailscale - * responses: - * 200: - * description: List of tailnet devices. - * content: - * application/json: - * schema: - * type: object - * properties: - * devices: - * type: array - * items: - * type: object - * 500: - * description: Failed to fetch Tailscale devices. - */ - router.get("/devices", authenticateJWT, async (_req, res) => { - try { - const settingsRepo = createCurrentSettingsRepository(); - const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? ""; - if (!apiKey) { - return res.json({ devices: [], hasApiKey: false }); - } - const apiBase = normalizeApiBase( - await settingsRepo.get("tailscale_api_base_url"), - ); - - 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, - }); - } - }); - - app.use("/tailscale", router); -} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 196177207..a51840800 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -92,6 +92,12 @@ * onto /plugin-api/network-topology/* and the generic key-value * ctx.storage the worker tier would require -- a URL and storage-model * change, not a capability gap. + * + * tailscale is here because its /devices route makes an outbound HTTP call + * to the Tailscale (or Headscale) Admin API using a settings-repo API key. + * The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented + * in SDK v1, see worker-bootstrap.ts), so there is currently no way for a + * worker-tier plugin to make an outbound HTTP request. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -103,6 +109,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "fleets", "automations", "network-topology", + "tailscale", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index 3ec42d906..88700b765 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,7 +39,7 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations and network-topology", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "automations", @@ -50,10 +50,11 @@ describe("first-party allowlist", () => { "proxmox", "remote-desktop", "ssh-terminal", + "tailscale", ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations and network-topology and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); @@ -63,6 +64,7 @@ describe("first-party allowlist", () => { expect(isFirstParty("fleets")).toBe(true); expect(isFirstParty("automations")).toBe(true); expect(isFirstParty("network-topology")).toBe(true); + expect(isFirstParty("tailscale")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -90,6 +92,7 @@ describe("runsInProcess", () => { expect( runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]), ).toBe(true); + expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -102,6 +105,7 @@ describe("runsInProcess", () => { expect(runsInProcess("fleets", ["hosts.read"])).toBe(false); expect(runsInProcess("automations", ["hosts.read"])).toBe(false); expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false); + expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -217,6 +221,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("network-topology"); }); + it("allows it for tailscale", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "tailscale", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("tailscale"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index cfda45d52..44d205b90 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -372,7 +372,8 @@ export type AdminSection = | "audit-log" | "ssl" | "plugins" - | "touch-input"; + | "touch-input" + | "tailscale"; export type ThemeId = | "dark" | "light" diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 38e8d672f..b7404af7f 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -96,6 +96,11 @@ const FleetsPanel = lazy(() => default: m.FleetsPanel, })), ); +const TailscaleDevicesPanel = lazy(() => + import("../../plugins/tailscale/frontend/TailscaleDevicesPanel").then( + (m) => ({ default: m.TailscaleDevicesPanel }), + ), +); const WorkspacesPanel = lazy(() => import("@/sidebar/WorkspacesPanel").then((m) => ({ default: m.WorkspacesPanel, @@ -2602,6 +2607,15 @@ export function AppShell({ /> )} + {railView === "tailscale" && ( + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); + }} + /> + )} + {railView === "ssh-tools" && (
{ - try { - const response = await authApi.get("/users/tailscale-settings"); - return response.data; - } catch (error) { - handleApiError(error, "fetch Tailscale settings"); - } -} - -export async function updateTailscaleSettings( - apiKey: string, - apiBaseUrl?: string, -): Promise<{ hasApiKey: boolean }> { - try { - const response = await authApi.patch("/users/tailscale-settings", { - apiKey, - apiBaseUrl, - }); - return response.data; - } catch (error) { - handleApiError(error, "update Tailscale settings"); - } -} - -export async function getTailscaleDevices(): Promise<{ - devices: Array<{ - id: string; - name: string; - hostname: string; - addresses: string[]; - os: string; - lastSeen: string; - }>; - hasApiKey: boolean; - error?: string; -}> { - try { - const response = await authApi.get("/tailscale/devices"); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - const data = error.response?.data; - if ( - data && - typeof data === "object" && - "hasApiKey" in data && - typeof data.hasApiKey === "boolean" - ) { - return data as { - devices: []; - hasApiKey: boolean; - error?: string; - }; - } - } - handleApiError(error, "fetch Tailscale devices"); - throw error; - } -} +// Tailscale settings/device API wrappers moved to +// plugins/tailscale/frontend/tailscale-api.ts. // ============================================================================ // GUACAMOLE SETTINGS diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 4dacc3156..a583c4f8b 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -46,7 +46,7 @@ import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx"; import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx"; -import { TailscaleCheckDialog } from "@/ssh/dialogs/TailscaleCheckDialog.tsx"; +import { TailscaleCheckDialog } from "../../../../plugins/tailscale/frontend/TailscaleCheckDialog.tsx"; import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx"; import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx"; import { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b9ffe1d67..547340c2b 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -737,6 +737,7 @@ "snippets": "Snippets", "macros": "Macros", "fleets": "Fleets", + "tailscale": "Tailscale", "automations": "Automations", "workspaces": "Workspaces", "hostManager": "Host Manager", @@ -3507,6 +3508,7 @@ "sectionApiKeys": "API Keys", "sectionAuditLog": "Audit Log", "sectionSsl": "SSL / Let's Encrypt", + "sectionTailscale": "Tailscale", "sectionTouchInput": "Touch Input", "touchEnabled": "Enable Touch Input", "touchEnabledDesc": "Allow one-finger touch scrolling in terminals", diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 405605cae..ce2dd3e03 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -50,8 +50,6 @@ import { getUserRoles, } from "@/main-axios"; import { - getTailscaleSettings, - updateTailscaleSettings, getHostDefaults, updateHostDefaults, getAnalyticsEnabled, @@ -66,6 +64,11 @@ import { type TerminalImageStorageTestResult, type BrandingSettings, } from "@/api/settings-api"; +import { + getTailscaleSettings, + updateTailscaleSettings, +} from "../../../plugins/tailscale/frontend/tailscale-api"; +import { AdminTailscaleSection } from "../../../plugins/tailscale/frontend/AdminTailscaleSection"; import { getSessionSharingGloballyEnabled, updateSessionSharingGloballyEnabled, @@ -1336,6 +1339,11 @@ export function AdminSettingsPanel({ handleSaveGuacamole={handleSaveGuacamole} logLevel={logLevel} handleSaveLogLevel={handleSaveLogLevel} + /> + + toggle("tailscale")} tailscaleApiKey={tailscaleApiKey} setTailscaleApiKey={setTailscaleApiKey} tailscaleApiBaseUrl={tailscaleApiBaseUrl} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 77c33d589..462c50165 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -77,11 +77,6 @@ type GeneralSettingsSectionProps = { handleSaveGuacamole: () => void; logLevel: string; handleSaveLogLevel: (level: string) => void; - tailscaleApiKey: string; - setTailscaleApiKey: Dispatch>; - tailscaleApiBaseUrl: string; - setTailscaleApiBaseUrl: Dispatch>; - handleSaveTailscaleApiKey: () => void; }; export function AdminGeneralSettingsSection({ @@ -138,11 +133,6 @@ export function AdminGeneralSettingsSection({ handleSaveGuacamole, logLevel, handleSaveLogLevel, - tailscaleApiKey, - setTailscaleApiKey, - tailscaleApiBaseUrl, - setTailscaleApiBaseUrl, - handleSaveTailscaleApiKey, }: GeneralSettingsSectionProps) { const { t } = useTranslation(); @@ -562,56 +552,6 @@ export function AdminGeneralSettingsSection({ )}
-
-
- - {t("admin.tailscaleApiKey")} - - - {t("admin.tailscaleApiKeyDescription")}{" "} - - {t("admin.tailscaleApiKeyDocsLink")} - - -
-
- setTailscaleApiKey(e.target.value)} - placeholder="tskey-api-... / hskey-api-..." - className="text-sm" - /> - -
-
- - - {t("admin.tailscaleApiBaseUrlDescription")} - - setTailscaleApiBaseUrl(e.target.value)} - placeholder="https://api.tailscale.com/api/v2" - className="text-sm" - /> -
-
-
{t("admin.logLevel")} diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 63d3e7b14..160b48871 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -32,6 +32,7 @@ export type RailView = | "automations" | "ai" | "fleets" + | "tailscale" | "workspaces" | "collab"; diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 185132ec0..adfe51d10 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -56,7 +56,8 @@ import { createCredential, adminCreateUserCredential, } from "@/main-axios"; -import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; +import { getHostDefaults } from "@/api/settings-api"; +import { getTailscaleDevices } from "../../../plugins/tailscale/frontend/tailscale-api"; import { getUserPreferences, saveUserPreferences, diff --git a/src/ui/sidebar/rail-items.ts b/src/ui/sidebar/rail-items.ts index e3f9c9ec9..b9222e453 100644 --- a/src/ui/sidebar/rail-items.ts +++ b/src/ui/sidebar/rail-items.ts @@ -11,6 +11,7 @@ import { Network, Play, Plug, + Radar, ScrollText, Server, Settings, @@ -139,6 +140,12 @@ export const RAIL_ITEMS: RailItemDef[] = [ rightDockable: true, }, { id: "fleets", icon: Boxes, labelKey: "nav.fleets", separatorAfter: true }, + { + id: "tailscale", + icon: Radar, + labelKey: "nav.tailscale", + separatorAfter: true, + }, { id: "automations", icon: Workflow, diff --git a/src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx b/src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx new file mode 100644 index 000000000..64440b039 --- /dev/null +++ b/src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AdminTailscaleSection } from "../../../../../plugins/tailscale/frontend/AdminTailscaleSection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("AdminTailscaleSection", () => { + it("renders the API key and base URL fields and saves on click", async () => { + const user = userEvent.setup(); + const handleSave = vi.fn(); + render( + , + ); + + expect( + ( + screen.getByPlaceholderText( + "tskey-api-... / hskey-api-...", + ) as HTMLInputElement + ).value, + ).toBe("tskey-api-abc"); + expect( + ( + screen.getByPlaceholderText( + "https://api.tailscale.com/api/v2", + ) as HTMLInputElement + ).value, + ).toBe("https://api.tailscale.com/api/v2"); + + await user.click(screen.getByText("common.save")); + expect(handleSave).toHaveBeenCalledOnce(); + }); + + it("does not render its content when closed", () => { + render( + , + ); + + expect( + screen.queryByPlaceholderText("tskey-api-... / hskey-api-..."), + ).toBeNull(); + }); +}); diff --git a/src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx b/src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx new file mode 100644 index 000000000..fcec0904c --- /dev/null +++ b/src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const getTailscaleDevicesMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../../../plugins/tailscale/frontend/tailscale-api", () => ({ + getTailscaleDevices: getTailscaleDevicesMock, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +import { TailscaleDevicesPanel } from "../../../../../plugins/tailscale/frontend/TailscaleDevicesPanel"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("TailscaleDevicesPanel", () => { + it("shows the no-api-key message when hasApiKey is false", async () => { + getTailscaleDevicesMock.mockResolvedValue({ + devices: [], + hasApiKey: false, + }); + + render(); + + await waitFor(() => + expect(screen.getByText("hosts.tailscaleNoApiKey")).toBeTruthy(), + ); + }); + + it("shows the empty-devices message when the tailnet has no devices", async () => { + getTailscaleDevicesMock.mockResolvedValue({ devices: [], hasApiKey: true }); + + render(); + + await waitFor(() => + expect(screen.getByText("hosts.tailscaleNoDevices")).toBeTruthy(), + ); + }); + + it("renders devices and connects with an ephemeral tailscale host", async () => { + const user = userEvent.setup(); + getTailscaleDevicesMock.mockResolvedValue({ + hasApiKey: true, + devices: [ + { + id: "d1", + name: "box", + hostname: "box", + addresses: ["100.64.0.1"], + os: "linux", + lastSeen: "2026-01-01T00:00:00Z", + }, + ], + }); + + const onConnect = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByText("box")).toBeTruthy()); + await user.click( + screen.getByText("newUi.sidebar.quickConnect.connectToTerminal"), + ); + + expect(onConnect).toHaveBeenCalledOnce(); + const [host, type] = onConnect.mock.calls[0]; + expect(type).toBe("terminal"); + expect(host.ip).toBe("100.64.0.1"); + expect(host.authType).toBe("tailscale"); + expect(host.username).toBe("root"); + }); +}); diff --git a/src/ui/tests/api/tailscale-settings-api.test.ts b/src/ui/tests/plugins/tailscale/tailscale-api.test.ts similarity index 93% rename from src/ui/tests/api/tailscale-settings-api.test.ts rename to src/ui/tests/plugins/tailscale/tailscale-api.test.ts index 5aa36b494..334b969da 100644 --- a/src/ui/tests/api/tailscale-settings-api.test.ts +++ b/src/ui/tests/plugins/tailscale/tailscale-api.test.ts @@ -9,7 +9,7 @@ vi.mock("@/main-axios", () => ({ handleApiError: handleApiErrorMock, })); -import { getTailscaleDevices } from "../../api/settings-api"; +import { getTailscaleDevices } from "../../../../../plugins/tailscale/frontend/tailscale-api"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/ui/tests/sidebar/rail-items.test.ts b/src/ui/tests/sidebar/rail-items.test.ts index 3d2dcf2bf..0ef8d4761 100644 --- a/src/ui/tests/sidebar/rail-items.test.ts +++ b/src/ui/tests/sidebar/rail-items.test.ts @@ -57,6 +57,7 @@ describe("RAIL_ITEMS", () => { "snippets", "macros", "fleets", + "tailscale", "automations", "ai", "history",