mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
// 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<string>("apiKey")) ?? "";
|
|
if (!apiKey) {
|
|
return res.json({ devices: [], hasApiKey: false });
|
|
}
|
|
const apiBase = normalizeApiBase(
|
|
(await context.settings.get<string>("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;
|
|
}
|