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
@@ -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();
|
|
||||||
if cmd.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
let (ok, output) = match tokio::process::Command::new(&cmd[0])
|
_ => None,
|
||||||
.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!("verify error: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
}
|
}
|
||||||
Err(e) => (false, format!("exec error: {e}")),
|
|
||||||
};
|
|
||||||
Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user