// 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"; 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(); /** * 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. */ 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, ); 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( mountOn: Router, ctx: PluginContext, ): void { context = ctx; mountOn.use(router); } /** Called from deactivate(). /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; }