Fleet P2b: node sandbox-readiness check (hardened workload on a node)
Proves a connected node can host hardened agent workloads end-to-end, without
touching the agent run loop (zero blast radius on existing agents).
- Daemon: typed `sb_check` op — pulls a tiny image and runs it fully locked down
(cap-drop ALL, no-new-privileges, no network, read-only rootfs, non-root,
memory/pids caps), then tears it down. Fixed command; nothing caller-supplied
runs (preserves the exec-hardening invariant).
- cm-api: NodeHub.sandbox_check + POST /api/nodes/{id}/sandbox-check.
- UI: a shield "sandbox check" button on each online node card streams the
result (✓ SANDBOX READY + container id/uname).
This validates the full provision→run→destroy mechanism on nodes. The remaining
P2 work — wiring real agent deploys to auto-place onto nodes — is its own
subsystem (a RemoteDriver reusing the local DockerDriver for security parity,
agent-image distribution to nodes, and node-routing in SandboxManager) and is
best done as a focused pass; it is intentionally NOT bundled here to keep the
core agent path untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f5f96508eb
commit
33aa9c0693
@@ -202,6 +202,12 @@ async fn handle_frame(text: &str, out: &mpsc::UnboundedSender<String>, ptys: &Pt
|
|||||||
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"sb_check" => {
|
||||||
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
|
let (ok, output) = sandbox_check().await;
|
||||||
|
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
"pty_open" => {
|
"pty_open" => {
|
||||||
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
||||||
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
||||||
@@ -295,6 +301,36 @@ async fn verify() -> (bool, String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Readiness check: provision a fully locked-down throwaway container (the same
|
||||||
|
/// hardening agent sandboxes use — cap-drop ALL, no-new-privileges, no network,
|
||||||
|
/// read-only rootfs, non-root, resource caps), run it, and tear it down. Proves
|
||||||
|
/// the node can host hardened agent workloads. Fixed command — nothing
|
||||||
|
/// caller-supplied runs.
|
||||||
|
async fn sandbox_check() -> (bool, String) {
|
||||||
|
let _ = tokio::process::Command::new("docker")
|
||||||
|
.args(["pull", "-q", "alpine:latest"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let out = tokio::process::Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"run", "--rm", "--cap-drop=ALL", "--security-opt", "no-new-privileges",
|
||||||
|
"--network", "none", "--read-only", "--tmpfs", "/tmp", "--user", "10001:10001",
|
||||||
|
"--memory", "256m", "--pids-limit", "128", "alpine:latest",
|
||||||
|
"sh", "-c", "echo sandbox-ok; id; uname -sm",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => (true, String::from_utf8_lossy(&o.stdout).trim().to_owned()),
|
||||||
|
Ok(o) => {
|
||||||
|
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||||
|
s.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||||
|
(false, s.trim().to_owned())
|
||||||
|
}
|
||||||
|
Err(e) => (false, format!("docker error: {e} — is Docker installed?")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort: join the user's tailnet (BYO Tailscale) and enable Tailscale SSH
|
/// 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
|
/// so they can reach this node keylessly. Failures are non-fatal — the WSS
|
||||||
/// control channel works regardless.
|
/// control channel works regardless.
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ impl NodeHub {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provision + run + tear down a fully hardened throwaway container on the
|
||||||
|
/// node (readiness check that it can host agent workloads).
|
||||||
|
pub async fn sandbox_check(&self, id: NodeId) -> Result<ExecOutput, String> {
|
||||||
|
self.request(id, |req_id| json!({ "t": "sb_check", "id": req_id }).to_string())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a typed request frame and await the node's matching result.
|
/// Send a typed request frame and await the node's matching result.
|
||||||
async fn request(
|
async fn request(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/nodes/live", get(routes::nodes::live))
|
.route("/api/nodes/live", get(routes::nodes::live))
|
||||||
.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}/sandbox-check", post(routes::nodes::sandbox_check))
|
||||||
.route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket))
|
.route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket))
|
||||||
.route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws))
|
.route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws))
|
||||||
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ pub async fn exec_test(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/nodes/{id}/sandbox-check` — run a hardened throwaway container on
|
||||||
|
/// the node to confirm it can host agent workloads.
|
||||||
|
pub async fn sandbox_check(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let node_id = NodeId::from(id);
|
||||||
|
nodes::get(&state.pool, node_id, user.workspace_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
match state.node_hub.sandbox_check(node_id).await {
|
||||||
|
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output }))),
|
||||||
|
Err(e) => Ok(Json(json!({ "ok": false, "output": e }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
|
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
|
||||||
pub async fn remove(
|
pub async fn remove(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// comes from the nodes registry (`GET /api/nodes`), polled every 3s.
|
// comes from the nodes registry (`GET /api/nodes`), polled every 3s.
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Terminal, Trash2 } from "lucide-react";
|
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { useFetchJson } from "@/lib/api/use-fetch";
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
|
||||||
@@ -79,6 +79,17 @@ function Bar({ label, pct, detail, color }: { label: string; pct: number; detail
|
|||||||
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
|
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
|
||||||
const h = node.health;
|
const h = node.health;
|
||||||
const [term, setTerm] = useState(false);
|
const [term, setTerm] = useState(false);
|
||||||
|
const [check, setCheck] = useState<{ ok: boolean; output: string } | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const runCheck = useCallback(() => {
|
||||||
|
setChecking(true);
|
||||||
|
setCheck(null);
|
||||||
|
fetch(`/api/nodes/${node.id}/sandbox-check`, { method: "POST" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ok: boolean; output: string }) => setCheck(d))
|
||||||
|
.catch((e: Error) => setCheck({ ok: false, output: e.message }))
|
||||||
|
.finally(() => setChecking(false));
|
||||||
|
}, [node.id]);
|
||||||
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
|
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
|
||||||
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
|
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
|
||||||
const remove = useCallback(() => {
|
const remove = useCallback(() => {
|
||||||
@@ -99,7 +110,10 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{node.status === "online" ? (
|
{node.status === "online" ? (
|
||||||
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button>
|
<>
|
||||||
|
<button type="button" onClick={runCheck} disabled={checking} title="Run sandbox readiness check" aria-label="Sandbox check" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(95,208,138,.3)", background: "rgba(95,208,138,.08)", color: "#5fd08a", cursor: checking ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><ShieldCheck size={14} /></button>
|
||||||
|
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
|
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +138,16 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
|
|||||||
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
|
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{checking || check ? (
|
||||||
|
<div style={{ borderRadius: 9, background: "#08080a", border: `1px solid ${check && !check.ok ? "rgba(255,111,97,.3)" : "rgba(95,208,138,.3)"}`, padding: 10 }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".08em", color: check && !check.ok ? "#ff8a7a" : "#5fd08a", marginBottom: check ? 6 : 0 }}>
|
||||||
|
{checking ? "RUNNING SANDBOX CHECK…" : check?.ok ? "✓ SANDBOX READY" : "✗ CHECK FAILED"}
|
||||||
|
</div>
|
||||||
|
{check ? (
|
||||||
|
<pre style={{ fontFamily: mono, fontSize: 10.5, color: "#bfe9d4", whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, maxHeight: 120, overflow: "auto" }}>{check.output}</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
|
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user