Fleet P1: BYO Tailscale + network metrics, Tailscale SSH, exec hardening
Security hardening: - The gateway no longer sends arbitrary shell to nodes. The WSS exec op is replaced by a typed `verify` op the daemon runs itself (fixed host+docker check); future container ops are typed too. cm-api NodeHub.verify() + the daemon's handle_command only dispatches vetted ops. BYO Tailscale: - migrations/0019_workspace_tailscale.sql + cm-db fleet_tailscale repo (store the user's Tailscale API key + tailnet, server-side only). - cm-api routes/tailscale.rs: POST/GET/DELETE /api/fleet/tailscale + GET /api/fleet/tailscale/devices (proxies api.tailscale.com device list). - Daemon: --tailscale-authkey → `tailscale up --authkey … --ssh` (enables Tailscale SSH for keyless user access); else `tailscale set --ssh=true`. Reports its tailscale IP (already). UI: - Fleet overview gains a Tailscale section: connect (key+tailnet) + live tailnet device status (online/last-seen/IP/os). Node cards show a copyable Tailscale SSH target (ssh <ip>). Remaining: P2 — RemoteDriver + placement (run agents on nodes) and the in-UI remote terminal (PTY proxied over the WSS channel). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2bdd0a23e8
commit
7332d69f8a
@@ -107,7 +107,11 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 11, color: "#9a9aa2", fontFamily: mono, paddingTop: 2 }}>
|
||||
<span>load {h.load1.toFixed(2)}</span>
|
||||
<span>· {h.containerCount} containers</span>
|
||||
{node.tailscaleIp ? <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}><Network size={11} /> {node.tailscaleIp}</span> : null}
|
||||
{node.tailscaleIp ? (
|
||||
<button type="button" onClick={() => navigator.clipboard?.writeText(`ssh ${node.tailscaleIp}`)} title={`Tailscale SSH — copy: ssh ${node.tailscaleIp}`} style={{ display: "inline-flex", alignItems: "center", gap: 4, border: 0, background: "transparent", color: "#5ec8d8", cursor: "pointer", fontFamily: mono, fontSize: 11, padding: 0 }}>
|
||||
<Network size={11} /> ssh {node.tailscaleIp}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -154,6 +158,76 @@ export function LocalHardware() {
|
||||
);
|
||||
}
|
||||
|
||||
interface TsDevice {
|
||||
name: string | null;
|
||||
addr: string | null;
|
||||
os: string | null;
|
||||
version: string | null;
|
||||
lastSeen: string | null;
|
||||
}
|
||||
|
||||
function tsOnline(lastSeen: string | null): boolean {
|
||||
if (!lastSeen) return false;
|
||||
const t = Date.parse(lastSeen);
|
||||
return Number.isFinite(t) && Date.now() - t < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/** Connect a workspace's Tailscale (BYO) and show its tailnet device metrics. */
|
||||
function TailscaleSection() {
|
||||
const { data: status, refresh } = useFetchJson<{ connected: boolean; tailnet: string | null }>("/api/fleet/tailscale");
|
||||
const { data: dev } = useFetchJson<{ connected: boolean; devices: TsDevice[]; error?: string }>(status?.connected ? "/api/fleet/tailscale/devices" : null);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [tailnet, setTailnet] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!apiKey.trim() || !tailnet.trim()) return;
|
||||
setBusy(true);
|
||||
fetch("/api/fleet/tailscale", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ apiKey, tailnet }) })
|
||||
.then(() => { setApiKey(""); refresh(); })
|
||||
.finally(() => setBusy(false));
|
||||
}, [apiKey, tailnet, refresh]);
|
||||
|
||||
const devices = dev?.devices ?? [];
|
||||
const online = devices.filter((d) => tsOnline(d.lastSeen)).length;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 4, borderRadius: 16, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 18 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 14 }}>
|
||||
<span style={{ width: 30, height: 30, borderRadius: 8, background: "rgba(94,200,216,.12)", border: "1px solid rgba(94,200,216,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Network size={15} /></span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5", flex: 1 }}>Tailscale network</span>
|
||||
{status?.connected ? (
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: "#5fd08a" }}>{online}/{devices.length} online · {status.tailnet}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!status?.connected ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<p style={{ fontSize: 12.5, color: "#9a9aa2", margin: 0, lineHeight: 1.5 }}>Connect your tailnet to see network status here. Paste a Tailscale API key and your tailnet (e.g. <code style={{ fontFamily: mono, color: "#cfcfd5" }}>example.com</code> or <code style={{ fontFamily: mono, color: "#cfcfd5" }}>your-org.ts.net</code>).</p>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input value={tailnet} onChange={(e) => setTailnet(e.target.value)} placeholder="tailnet" style={{ flex: "1 1 160px", padding: "9px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#08080a", color: "#f3f3f5", fontSize: 13 }} />
|
||||
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="tskey-api-…" type="password" style={{ flex: "2 1 240px", padding: "9px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#08080a", color: "#f3f3f5", fontSize: 13, fontFamily: mono }} />
|
||||
<button type="button" onClick={connect} disabled={busy || !apiKey.trim() || !tailnet.trim()} style={{ padding: "9px 16px", borderRadius: 9, border: 0, background: apiKey.trim() && tailnet.trim() ? "#5ec8d8" : "rgba(94,200,216,.3)", color: "#04222a", fontSize: 13, fontWeight: 700, cursor: busy ? "default" : "pointer" }}>{busy ? "Connecting…" : "Connect"}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div style={{ fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>{dev?.error ? `Tailscale: ${dev.error}` : "No devices on this tailnet yet."}</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{devices.map((d, i) => (
|
||||
<div key={d.addr ?? i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 11px", borderRadius: 9, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: tsOnline(d.lastSeen) ? "#5fd08a" : "#6a6a72" }} />
|
||||
<span style={{ fontSize: 13, color: "#cfcfd5", fontWeight: 600, flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{d.name ?? "device"}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: "#7a7a82" }}>{d.addr}</span>
|
||||
{d.os ? <span style={{ fontFamily: mono, fontSize: 10.5, color: "#5a5a62" }}>{d.os}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetOverview() {
|
||||
const { nodes } = useNodes();
|
||||
const online = nodes.filter((n) => n.status === "online");
|
||||
@@ -197,6 +271,10 @@ export function FleetOverview() {
|
||||
))}
|
||||
{nodes.length === 0 ? <span style={{ fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>No nodes yet — add one under Local hardware.</span> : null}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 26 }}>
|
||||
<TailscaleSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user