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
+49 -25
View File
@@ -16,12 +16,13 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let (server, token) = parse_args(); let (server, token, ts_authkey) = parse_args();
if server.is_empty() || token.is_empty() { if server.is_empty() || token.is_empty() {
eprintln!("usage: clawmates-node --server <https://gateway> --token <token>"); eprintln!("usage: clawmates-node --server <https://gateway> --token <token> [--tailscale-authkey <key>]");
eprintln!(" (or set CLAWMATES_SERVER / CLAWMATES_TOKEN)"); eprintln!(" (or set CLAWMATES_SERVER / CLAWMATES_TOKEN / CLAWMATES_TS_AUTHKEY)");
std::process::exit(2); std::process::exit(2);
} }
tailscale_up(&ts_authkey);
let ws_url = ws_url(&server, &token); let ws_url = ws_url(&server, &token);
println!("clawmates-node {VERSION} connecting to {server}"); println!("clawmates-node {VERSION} connecting to {server}");
loop { loop {
@@ -32,14 +33,16 @@ async fn main() {
} }
} }
fn parse_args() -> (String, String) { fn parse_args() -> (String, String, String) {
let mut server = String::new(); let mut server = String::new();
let mut token = String::new(); let mut token = String::new();
let mut ts_authkey = String::new();
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(a) = args.next() { while let Some(a) = args.next() {
match a.as_str() { match a.as_str() {
"--server" => server = args.next().unwrap_or_default(), "--server" => server = args.next().unwrap_or_default(),
"--token" => token = args.next().unwrap_or_default(), "--token" => token = args.next().unwrap_or_default(),
"--tailscale-authkey" => ts_authkey = args.next().unwrap_or_default(),
_ => {} _ => {}
} }
} }
@@ -49,7 +52,10 @@ fn parse_args() -> (String, String) {
if token.is_empty() { if token.is_empty() {
token = std::env::var("CLAWMATES_TOKEN").unwrap_or_default(); token = std::env::var("CLAWMATES_TOKEN").unwrap_or_default();
} }
(server, token) if ts_authkey.is_empty() {
ts_authkey = std::env::var("CLAWMATES_TS_AUTHKEY").unwrap_or_default();
}
(server, token, ts_authkey)
} }
fn ws_url(server: &str, token: &str) -> String { fn ws_url(server: &str, token: &str) -> String {
@@ -168,35 +174,53 @@ fn tailscale_ip() -> Option<String> {
(!ip.is_empty()).then_some(ip) (!ip.is_empty()).then_some(ip)
} }
/// Run a command the gateway requested and serialize the result frame. /// Handle a typed request from the gateway. The gateway never sends arbitrary
/// shell — only vetted ops the daemon runs itself (verify today; container ops
/// later), so the host attack surface stays minimal.
async fn handle_command(text: &str) -> Option<String> { async fn handle_command(text: &str) -> Option<String> {
let v: Value = serde_json::from_str(text).ok()?; let v: Value = serde_json::from_str(text).ok()?;
if v.get("t").and_then(Value::as_str) != Some("exec") {
return None;
}
let id = v.get("id").and_then(Value::as_u64)?; let id = v.get("id").and_then(Value::as_u64)?;
let cmd: Vec<String> = v match v.get("t").and_then(Value::as_str)? {
.get("cmd")? "verify" => {
.as_array()? let (ok, output) = verify().await;
.iter() Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string())
.filter_map(|x| x.as_str().map(str::to_owned)) }
.collect(); _ => None,
if cmd.is_empty() {
return None;
} }
let (ok, output) = match tokio::process::Command::new(&cmd[0]) }
.args(&cmd[1..])
/// The daemon's built-in host check (fixed command — nothing caller-supplied
/// executes): kernel info + Docker presence.
async fn verify() -> (bool, String) {
let out = tokio::process::Command::new("sh")
.arg("-lc")
.arg("uname -a; echo '---'; docker version --format 'docker {{.Server.Version}}' 2>/dev/null || echo 'docker: not found'")
.output() .output()
.await .await;
{ match out {
Ok(o) => { Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned(); let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
if !o.stderr.is_empty() { if !o.stderr.is_empty() {
s.push_str(&String::from_utf8_lossy(&o.stderr)); s.push_str(&String::from_utf8_lossy(&o.stderr));
} }
(o.status.success(), s) (true, s.trim().to_owned())
} }
Err(e) => (false, format!("exec error: {e}")), Err(e) => (false, format!("verify error: {e}")),
}; }
Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string()) }
/// Best-effort: join the user's tailnet (BYO Tailscale) and enable Tailscale SSH
/// so they can reach this node keylessly. Failures are non-fatal — the WSS
/// control channel works regardless.
fn tailscale_up(authkey: &str) {
if !authkey.is_empty() {
let _ = std::process::Command::new("tailscale")
.args(["up", "--authkey", authkey, "--ssh", "--accept-routes"])
.status();
} else {
// Already authed by the user? Just make sure SSH is on.
let _ = std::process::Command::new("tailscale")
.args(["set", "--ssh=true"])
.status();
}
} }
+15 -4
View File
@@ -48,15 +48,26 @@ impl NodeHub {
self.conns.lock().await.get(&id).cloned() self.conns.lock().await.get(&id).cloned()
} }
/// Run a command on a connected node and await its output (with a timeout). /// Run the node's built-in verification (host + docker check) and await its
pub async fn exec(&self, id: NodeId, cmd: &[String]) -> Result<ExecOutput, String> { /// output. The gateway never sends arbitrary shell — only typed ops the
/// daemon vets and runs itself (verify today; container ops later).
pub async fn verify(&self, id: NodeId) -> Result<ExecOutput, String> {
self.request(id, |req_id| json!({ "t": "verify", "id": req_id }).to_string())
.await
}
/// Send a typed request frame and await the node's matching result.
async fn request(
&self,
id: NodeId,
frame: impl FnOnce(u64) -> String,
) -> Result<ExecOutput, String> {
let conn = self.get(id).await.ok_or("node is not connected")?; let conn = self.get(id).await.ok_or("node is not connected")?;
let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed); let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
conn.pending.lock().await.insert(req_id, tx); conn.pending.lock().await.insert(req_id, tx);
let frame = json!({ "t": "exec", "id": req_id, "cmd": cmd }).to_string();
conn.tx conn.tx
.send(frame) .send(frame(req_id))
.map_err(|_| "node channel closed".to_string())?; .map_err(|_| "node channel closed".to_string())?;
match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await { match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await {
Ok(Ok(out)) => Ok(out), Ok(Ok(out)) => Ok(out),
+7
View File
@@ -114,6 +114,13 @@ pub fn router(state: AppState) -> Router {
.route("/api/nodes/agent", get(routes::nodes::agent_ws)) .route("/api/nodes/agent", get(routes::nodes::agent_ws))
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test)) .route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
.route(
"/api/fleet/tailscale",
get(routes::tailscale::status)
.post(routes::tailscale::connect)
.delete(routes::tailscale::disconnect),
)
.route("/api/fleet/tailscale/devices", get(routes::tailscale::devices))
.route("/mcp", post(mcp_door::mcp)) .route("/mcp", post(mcp_door::mcp))
.route("/api/auth/login", post(routes::auth::login)) .route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
+1
View File
@@ -20,6 +20,7 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod slack; pub mod slack;
pub mod structure; pub mod structure;
pub mod tailscale;
pub mod team; pub mod team;
pub mod teams; pub mod teams;
pub mod terminal; pub mod terminal;
+1 -6
View File
@@ -109,12 +109,7 @@ pub async fn exec_test(
let node = nodes::get(&state.pool, node_id, user.workspace_id) let node = nodes::get(&state.pool, node_id, user.workspace_id)
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
let cmd = vec![ match state.node_hub.verify(node_id).await {
"sh".to_owned(),
"-lc".to_owned(),
"uname -a; echo '---'; docker version --format 'docker {{.Server.Version}}' 2>/dev/null || echo 'docker: not found'".to_owned(),
];
match state.node_hub.exec(node_id, &cmd).await {
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output, "node": node.name }))), Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output, "node": node.name }))),
Err(e) => Ok(Json(json!({ "ok": false, "output": e, "node": node.name }))), Err(e) => Ok(Json(json!({ "ok": false, "output": e, "node": node.name }))),
} }
+95
View File
@@ -0,0 +1,95 @@
//! Bring-your-own Tailscale: connect a workspace's tailnet (store its API key)
//! and proxy the Tailscale device list for fleet network metrics. The API key is
//! used server-side only (never returned to the client).
use axum::extract::State;
use axum::Json;
use cm_db::repo::fleet_tailscale;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{ApiError, AppState, Authed};
#[derive(Deserialize)]
pub struct ConnectReq {
#[serde(rename = "apiKey")]
pub api_key: String,
pub tailnet: String,
}
/// `POST /api/fleet/tailscale` — store the workspace's Tailscale API key + tailnet.
pub async fn connect(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<ConnectReq>,
) -> Result<Json<Value>, ApiError> {
let api_key = req.api_key.trim();
let tailnet = req.tailnet.trim();
if api_key.is_empty() || tailnet.is_empty() {
return Ok(Json(json!({ "ok": false, "error": "apiKey and tailnet are required" })));
}
fleet_tailscale::set(&state.pool, user.workspace_id, api_key, tailnet).await?;
Ok(Json(json!({ "ok": true, "tailnet": tailnet })))
}
/// `GET /api/fleet/tailscale` — whether Tailscale is connected (+ the tailnet).
pub async fn status(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let conn = fleet_tailscale::get(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "connected": conn.is_some(), "tailnet": conn.map(|(_, t)| t) })))
}
/// `DELETE /api/fleet/tailscale` — disconnect Tailscale.
pub async fn disconnect(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
fleet_tailscale::delete(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "ok": true })))
}
/// `GET /api/fleet/tailscale/devices` — proxy the Tailscale tailnet device list
/// (online/last-seen/IP/version) for the Fleet network overview.
pub async fn devices(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let Some((api_key, tailnet)) = fleet_tailscale::get(&state.pool, user.workspace_id).await? else {
return Ok(Json(json!({ "connected": false, "devices": [] })));
};
let url = format!("https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices");
let resp = reqwest::Client::new().get(&url).bearer_auth(&api_key).send().await;
let body = match resp {
Ok(r) if r.status().is_success() => r.json::<Value>().await.unwrap_or_else(|_| json!({})),
Ok(r) => {
return Ok(Json(
json!({ "connected": true, "tailnet": tailnet, "error": format!("tailscale api {}", r.status()), "devices": [] }),
));
}
Err(e) => {
return Ok(Json(
json!({ "connected": true, "tailnet": tailnet, "error": e.to_string(), "devices": [] }),
));
}
};
let devices: Vec<Value> = body
.get("devices")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.map(|d| {
json!({
"name": d.get("hostname").or_else(|| d.get("name")).and_then(Value::as_str),
"addr": d.get("addresses").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str),
"os": d.get("os").and_then(Value::as_str),
"version": d.get("clientVersion").and_then(Value::as_str),
"lastSeen": d.get("lastSeen").and_then(Value::as_str),
})
})
.collect()
})
.unwrap_or_default();
Ok(Json(json!({ "connected": true, "tailnet": tailnet, "devices": devices })))
}
+49
View File
@@ -0,0 +1,49 @@
//! Per-workspace Tailscale connection (BYO tailnet): the API key + tailnet we
//! use to read fleet network metrics. Used server-side only.
use cm_domain::WorkspaceId;
use sqlx::{PgPool, Row};
use crate::DbError;
/// Store (or replace) a workspace's Tailscale API key + tailnet.
pub async fn set(
pool: &PgPool,
workspace_id: WorkspaceId,
api_key: &str,
tailnet: &str,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO workspace_tailscale (workspace_id, api_key, tailnet, connected_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (workspace_id) DO UPDATE SET
api_key = excluded.api_key, tailnet = excluded.tailnet, connected_at = now()",
)
.bind(workspace_id.as_uuid())
.bind(api_key)
.bind(tailnet)
.execute(pool)
.await?;
Ok(())
}
/// Get a workspace's stored (api_key, tailnet), if connected.
pub async fn get(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<Option<(String, String)>, DbError> {
let row = sqlx::query("SELECT api_key, tailnet FROM workspace_tailscale WHERE workspace_id = $1")
.bind(workspace_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.map(|r| (r.get("api_key"), r.get("tailnet"))))
}
/// Disconnect a workspace's Tailscale.
pub async fn delete(pool: &PgPool, workspace_id: WorkspaceId) -> Result<(), DbError> {
sqlx::query("DELETE FROM workspace_tailscale WHERE workspace_id = $1")
.bind(workspace_id.as_uuid())
.execute(pool)
.await?;
Ok(())
}
+1
View File
@@ -6,6 +6,7 @@ pub mod companies;
pub mod connections; pub mod connections;
pub mod credits; pub mod credits;
pub mod files; pub mod files;
pub mod fleet_tailscale;
pub mod messages; pub mod messages;
pub mod nodes; pub mod nodes;
pub mod orgs; pub mod orgs;
@@ -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 }}> <div style={{ display: "flex", gap: 14, fontSize: 11, color: "#9a9aa2", fontFamily: mono, paddingTop: 2 }}>
<span>load {h.load1.toFixed(2)}</span> <span>load {h.load1.toFixed(2)}</span>
<span>· {h.containerCount} containers</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>
</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() { export function FleetOverview() {
const { nodes } = useNodes(); const { nodes } = useNodes();
const online = nodes.filter((n) => n.status === "online"); 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} {nodes.length === 0 ? <span style={{ fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>No nodes yet — add one under Local hardware.</span> : null}
</div> </div>
<div style={{ marginTop: 26 }}>
<TailscaleSection />
</div>
</div> </div>
</div> </div>
); );
+10
View File
@@ -0,0 +1,10 @@
-- Bring-your-own Tailscale: a workspace connects its own tailnet so we can read
-- fleet network metrics (the Tailscale device list) via their API key. The key
-- is only ever used server-side for outbound calls to api.tailscale.com.
-- TODO: move api_key into the cm-secrets broker store (like `connections`).
CREATE TABLE workspace_tailscale (
workspace_id UUID PRIMARY KEY REFERENCES workspaces (id) ON DELETE CASCADE,
api_key TEXT NOT NULL,
tailnet TEXT NOT NULL,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);