Fleet P1: BYO Tailscale + network metrics, Tailscale SSH, exec hardening
ci / gates (push) Failing after 6s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

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:
Omar Sobh
2026-06-24 10:27:58 -07:00
co-authored by Claude Opus 4.8
parent 2bdd0a23e8
commit 7332d69f8a
10 changed files with 307 additions and 36 deletions
@@ -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>
);