mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: finish the tailscale plugin, including SSH auth (B3)
This commit is contained in:
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"],
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user