feat: finish the tailscale plugin, including SSH auth (B3)

This commit is contained in:
LukeGus committed 2026-09-23 09:41:20 -05:00
1 parent c77301577f
commit d2ff5fc78f
35 files changed
+1251 -526

No files matched your search

@@ -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);
}
@@ -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(),
};
},
),
);
}
@@ -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(() => {
@@ -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<string, CardDefinition> = {
"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<string, unknown>>;
}>,
): Record<string, CardDefinition> {
const out: Record<string, CardDefinition> = {};
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 }) => <Comp hostId={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<string, CardDefinition>,
): CardDefinition | undefined {
return CARD_DEFINITIONS[id] ?? extra?.[id];
}
export { defaultColSpanFor, defaultHeightFor };
@@ -1,171 +0,0 @@
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";
interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
export function TailscaleManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<TailscaleData>(
hostId,
"tailscale",
);
const { busy, run } = useManagerAction(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,
},
);
};
const filteredPeers = (data?.peers ?? []).filter(
(p) =>
!query ||
p.hostname.toLowerCase().includes(query.toLowerCase()) ||
p.tailscaleIPs.some((ip) => ip.includes(query)),
);
const isNotInstalled = data && !data.installed;
return (
<ManagerCardShell
title={t("hostMetrics.managers.tailscale")}
icon={<Power className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!!isNotInstalled}
emptyMessage={t("hostMetrics.managers.tsNotInstalled")}
>
{data && (
<div className="flex flex-col">
{/* Status + toggle row */}
<div className="flex items-center justify-between border-b border-border/50 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${data.running ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold">
{data.running
? t("hostMetrics.managers.tsRunning")
: t("hostMetrics.managers.tsStopped")}
</span>
{data.hostname && (
<span className="truncate font-mono text-[10px] text-muted-foreground">
{data.hostname}
</span>
)}
{data.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground/60"
>
{ip}
</span>
))}
</div>
</div>
<div className="flex shrink-0 items-center">
<button
onClick={handleToggle}
disabled={busy || !data.installed}
title={
data.running
? t("hostMetrics.managers.tsDisable")
: t("hostMetrics.managers.tsEnable")
}
className={`flex size-6 items-center justify-center transition-colors disabled:opacity-40 ${
data.running
? "text-accent-brand hover:bg-muted hover:text-destructive"
: "text-muted-foreground hover:bg-muted hover:text-accent-brand"
}`}
>
<Power className="size-3.5" />
</button>
</div>
</div>
{/* Exit node badge */}
{data.exitNodeInUse && (
<div className="border-b border-border/50 px-0 py-1.5">
<span className="text-[10px] text-blue-500">
{t("hostMetrics.managers.tsExitNodeActive")}
</span>
</div>
)}
{/* Peers */}
{(data.peers?.length ?? 0) > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={filteredPeers.length}
/>
)}
{filteredPeers.map((peer) => (
<div
key={peer.hostname}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${peer.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">
{peer.hostname}
</span>
{peer.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground"
>
{ip}
</span>
))}
</div>
</div>
{peer.isExitNode && (
<span className="shrink-0 text-[10px] text-blue-500">
{t("hostMetrics.managers.tsExitNode")}
</span>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
@@ -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"],
});
}