//! 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, nodes, workspace_placement}; use cm_domain::NodeId; use serde::Deserialize; use serde_json::{json, Value}; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; /// `GET /api/fleet/placement` — the workspace's default placement node (or null). pub async fn placement_get( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let node = workspace_placement::get(&state.pool, user.workspace_id).await?; Ok(Json(json!({ "node": node }))) } #[derive(Deserialize)] pub struct PlacementReq { pub node: String, } /// `PUT /api/fleet/placement` — set where new agent sandboxes provision. "local" /// (or empty) reverts to the gateway host; a node id must belong to the caller. pub async fn placement_set( State(state): State, Authed(user): Authed, Json(req): Json, ) -> Result, ApiError> { let node = req.node.trim(); if node.is_empty() || node == "local" { workspace_placement::clear(&state.pool, user.workspace_id).await?; return Ok(Json(json!({ "ok": true, "node": "local" }))); } let nid = NodeId::from(node.parse::().map_err(|_| ApiError::NotFound)?); nodes::get(&state.pool, nid, user.workspace_id) .await? .ok_or(ApiError::NotFound)?; workspace_placement::set(&state.pool, user.workspace_id, node).await?; Ok(Json(json!({ "ok": true, "node": node }))) } #[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, Authed(user): Authed, Json(req): Json, ) -> Result, 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, Authed(user): Authed, ) -> Result, 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, Authed(user): Authed, ) -> Result, 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, Authed(user): Authed, ) -> Result, 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::().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 = 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 }), )) }