From dd86330ecafc6c4666732069edfc22ef54be3eab Mon Sep 17 00:00:00 2001 From: Alex Newman <806363+posix4e@users.noreply.github.com> Date: Sun, 10 May 2026 11:02:38 +0000 Subject: [PATCH 01/13] Add mobile prompt inbox for shell --- src/shell.rs | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index 87d413c..f1548ef 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1462,6 +1462,14 @@ main { max-width:none; padding:0; height:100dvh; display:grid; grid-template-col .notify-item .notify-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:700; } .notify-item .notify-time { color:#8791a5; font-size:10px; font-weight:400; white-space:nowrap; } .notify-item .notify-body { margin-top:3px; color:#8791a5; font-size:11px; line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.prompt-feed { display:flex; flex-direction:column; gap:8px; margin:0 0 12px; } +.prompt-card { background:#171c29; border:1px solid #7aa2f7; border-radius:7px; padding:10px; min-width:0; box-shadow:0 0 0 1px #1d3358 inset; } +.prompt-card .prompt-head { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:800; } +.prompt-card .prompt-meta { color:#8791a5; font-size:10px; white-space:nowrap; } +.prompt-card .prompt-body { margin-top:6px; color:#d7deea; font-size:12px; line-height:1.45; overflow-wrap:anywhere; } +.prompt-actions { display:flex; flex-wrap:wrap; gap:6px; margin-top:9px; } +.prompt-actions button { flex:1 1 64px; min-width:0; padding:8px 9px; font-size:12px; } +.prompt-actions button.dismiss { flex:0 0 auto; color:#8791a5; background:#171c29; border:1px solid #2b3242; } .notify-badge { display:none; min-width:18px; height:18px; border-radius:999px; background:#7aa2f7; color:#05070a; font-size:11px; font-weight:800; align-items:center; justify-content:center; padding:0 5px; } .notify-badge.active { display:inline-flex; } .toast-stack { position:fixed; top:60px; right:14px; z-index:50; display:flex; flex-direction:column; gap:8px; width:min(340px, calc(100vw - 28px)); pointer-events:none; } @@ -1511,6 +1519,8 @@ button.secondary { background:#252a36; color:#d7deea; }
System
+
Prompt inbox
+
Notifications
@@ -1592,6 +1602,9 @@ let cachedWorkloads = []; let activePanel = "system"; let notifications = loadNotificationHistory(); let unreadNotifications = 0; +let promptRequests = loadPromptHistory(); +let promptScanBuffer = ""; +let lastPromptKey = ""; const decoder = new TextDecoder(); let serviceWorkerReady = null; installPwaMetadata(); @@ -1612,6 +1625,7 @@ async function refresh() { api("/api/workloads").catch(() => []) ]); renderSystem(system); + renderPromptFeed(); renderNotificationFeed(); cachedRecipes = recipes; renderRecipes(); @@ -1803,11 +1817,11 @@ async function attach(id) { }; ws.onmessage = ev => { if (typeof ev.data === "string") { - scanNotifications(ev.data); + scanTerminalOutput(ev.data); term.write(ev.data); } else { const bytes = new Uint8Array(ev.data); - scanNotifications(bytes); + scanTerminalOutput(bytes); term.write(bytes); } }; @@ -2001,6 +2015,10 @@ async function sendResize() { body: JSON.stringify({cols: term.cols, rows: term.rows}) }).catch(() => {}); } +function scanTerminalOutput(data) { + const text = scanNotifications(data); + scanPromptRequests(text); +} function scanNotifications(data) { const text = typeof data === "string" ? data : decoder.decode(data, {stream:true}); oscBuffer += text; @@ -2014,6 +2032,124 @@ function scanNotifications(data) { } if (consumed > 0) oscBuffer = oscBuffer.slice(consumed); if (oscBuffer.length > 8192) oscBuffer = oscBuffer.slice(-1024); + return text; +} +function scanPromptRequests(text) { + if (!text) return; + promptScanBuffer = stripAnsi(promptScanBuffer + text).replace(/\r/g, "\n"); + if (promptScanBuffer.length > 12000) promptScanBuffer = promptScanBuffer.slice(-8000); + const lines = promptScanBuffer.split("\n").map(l => l.trim()).filter(Boolean); + const recent = lines.slice(-18); + const prompt = detectPrompt(recent); + if (!prompt) return; + const key = `${current || "none"}:${prompt.kind}:${prompt.body}:${prompt.actions.map(a => a.input).join("|")}`; + if (key === lastPromptKey || promptRequests.some(p => p.key === key)) return; + lastPromptKey = key; + const item = { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + key, + sessionId: current, + title: prompt.title, + body: prompt.body, + actions: prompt.actions, + ts: Date.now() + }; + promptRequests.unshift(item); + promptRequests = promptRequests.slice(0, 20); + savePromptHistory(); + renderPromptFeed(); + notify("Codex needs input", prompt.body); +} +function detectPrompt(lines) { + const windowText = lines.join("\n"); + if (!/\?|select|choose|enter|approve|allow|continue|proceed|permission|confirmation/i.test(windowText)) return null; + const numbered = []; + for (const line of lines.slice(-12)) { + const m = line.match(/^(?:\[?([1-9])\]?[\).:]?|([1-9])\s+[-:])\s+(.{1,90})$/); + if (!m) continue; + const n = m[1] || m[2]; + const label = `${n}: ${(m[3] || "").trim()}`.slice(0, 44); + if (!numbered.some(a => a.input === `${n}\n`)) numbered.push({label, input:`${n}\n`}); + } + if (numbered.length >= 2 && numbered.length <= 6) { + return { + kind: "numbered", + title: "Input requested", + body: summarizePrompt(lines), + actions: numbered + }; + } + const yn = windowText.match(/\((?:y\/n|Y\/n|y\/N)\)|\[(?:y\/n|Y\/n|y\/N)\]/); + if (yn) { + return { + kind: "yesno", + title: "Confirmation requested", + body: summarizePrompt(lines), + actions: [{label:"Yes", input:"y\n"}, {label:"No", input:"n\n"}] + }; + } + if (/press enter|enter for no changes|hit enter/i.test(windowText)) { + return { + kind: "enter", + title: "Continue requested", + body: summarizePrompt(lines), + actions: [{label:"Enter", input:"\n"}] + }; + } + return null; +} +function summarizePrompt(lines) { + const useful = lines.slice(-8).filter(l => !/^[\s\-\|]+$/.test(l)); + return useful.join(" ").replace(/\s+/g, " ").slice(0, 220); +} +function renderPromptFeed() { + const root = document.getElementById("prompt-feed"); + if (!root) return; + const pending = promptRequests.filter(p => !p.dismissed).slice(0, 8); + if (!pending.length) { + root.innerHTML = `
No pending prompts
`; + return; + } + root.innerHTML = pending.map(p => ` +
+
${escapeHtml(p.title || "Input requested")}${escapeHtml(formatClock(p.ts))}
+
${escapeHtml(p.body || "")}
+
+ ${(p.actions || []).map((a, i) => ``).join("")} + +
+
+ `).join(""); + root.querySelectorAll("button[data-action]").forEach(btn => { + btn.onclick = () => sendPromptInput(btn.dataset.prompt, Number(btn.dataset.action)); + }); + root.querySelectorAll("button[data-dismiss-prompt]").forEach(btn => { + btn.onclick = () => dismissPrompt(btn.dataset.dismissPrompt); + }); +} +function sendPromptInput(id, actionIndex) { + if (currentKind !== "session" || !ws || ws.readyState !== WebSocket.OPEN) { + document.getElementById("status").textContent = "Attach session before sending prompt input"; + return; + } + const prompt = promptRequests.find(p => p.id === id); + const action = prompt && Array.isArray(prompt.actions) ? prompt.actions[actionIndex] : null; + if (!action) return; + const input = action.input || ""; + ws.send(input || ""); + dismissPrompt(id); + document.getElementById("status").textContent = "Prompt input sent"; +} +function dismissPrompt(id) { + const prompt = promptRequests.find(p => p.id === id); + if (prompt) prompt.dismissed = true; + savePromptHistory(); + renderPromptFeed(); +} +function stripAnsi(value) { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); } function notify(title, body) { const item = { @@ -2073,6 +2209,17 @@ function loadNotificationHistory() { function saveNotificationHistory() { localStorage.setItem("dd-shell-notifications", JSON.stringify(notifications.slice(0, 50))); } +function loadPromptHistory() { + try { + const parsed = JSON.parse(localStorage.getItem("dd-shell-prompts") || "[]"); + return Array.isArray(parsed) ? parsed.slice(0, 20) : []; + } catch (_) { + return []; + } +} +function savePromptHistory() { + localStorage.setItem("dd-shell-prompts", JSON.stringify(promptRequests.slice(0, 20))); +} function showToast(item) { const stack = document.getElementById("toast-stack"); if (!stack) return; From 96a8d5a401b188a9cb66db0e600c8f92ba748fe0 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 11:13:22 +0000 Subject: [PATCH 02/13] Route shell sessions over Noise --- .github/workflows/deploy-cp.yml | 1 - Cargo.lock | 20 +- Cargo.toml | 3 +- README.md | 35 +- apps/README.md | 29 +- apps/_infra/local-cp.sh | 3 + docs/sessiond-central-ui-plan.md | 84 ++-- src/agent.rs | 13 +- src/cp.rs | 1 + src/lib.rs | 1 + src/main.rs | 6 +- src/noise_client.rs | 721 ++++++++++++++++++++++++++++++ src/noise_gateway/allowlist.rs | 27 ++ src/noise_gateway/mod.rs | 1 + src/noise_gateway/noise.rs | 65 ++- src/noise_gateway/upstream.rs | 137 +++++- src/shell.rs | 726 +------------------------------ 17 files changed, 1078 insertions(+), 795 deletions(-) create mode 100644 src/noise_client.rs diff --git a/.github/workflows/deploy-cp.yml b/.github/workflows/deploy-cp.yml index 3ebe892..dcc86d5 100644 --- a/.github/workflows/deploy-cp.yml +++ b/.github/workflows/deploy-cp.yml @@ -269,7 +269,6 @@ jobs: DD_AUTH_COOKIE_DOMAIN="$DD_AUTH_COOKIE_DOMAIN" \ DD_AUTH_COOKIE_SECRET="$DD_AUTH_COOKIE_SECRET" \ bake apps/dd-shell/workload.json.tmpl - bake apps/ee-proxy/workload.json.tmpl } | jq -cs '.') # EE_CAPTURE_SOCKET tells EE (post-capture-socket patch) to tee diff --git a/Cargo.lock b/Cargo.lock index 865fe94..55875bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,6 +386,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tokio-tungstenite", "urlencoding", "uuid", "x25519-dalek", @@ -755,7 +756,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -1448,7 +1449,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -1908,8 +1909,12 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -1996,6 +2001,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -2228,6 +2235,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index cd7cf19..ccb2218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,8 @@ sha2 = "0.10" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } sysinfo = { version = "0.33", default-features = false, features = ["system", "disk", "network"] } thiserror = "2" -tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "sync"] } +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "io-std", "sync"] } +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/README.md b/README.md index 60df909..7770c04 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,37 @@ The agent verifies the OIDC token against GitHub's JWKS, checks `repository_owne ## Terminal access -Each VM runs `dd-shell` as a workload on a `-shell` labelled subdomain (for example `app-shell.devopsdefender.com` or `-shell.devopsdefender.com`). DD gates it behind the same GitHub App broker session as the dashboards. The shell UI separates observed read-only workload logs from controlled read-write PTY sessions. Read-only viewing does not change integrity state because it cannot send input or signals; read-write PTYs are controlled as soon as they exist and keep encrypted transcript history inside the enclave. +Each VM runs `dd-sessiond` as the local session supervisor. `dd-sessiond` owns +PTYs, child process groups, resize/close control, and encrypted transcript +history inside the enclave. + +Native clients use a paired device key against the agent's `/noise/ws` endpoint. +The bootstrap flow fetches `/health`, appraises `noise.quote_b64` with Intel +Trust Authority, checks that the quote binds `noise.pubkey_hex` into TDX +`report_data`, and then runs Noise_IK over WebSocket. The exposed session RPC surface is +`shell.list_recipes`, `shell.list_sessions`, `shell.create_session`, +`shell.replay_session`, `shell.resize_session`, `shell.close_session`, and the +streaming `shell.attach_session` method. Session control and PTY bytes flow +inside the Noise transport to the agent and then to local `dd-sessiond`; the CP +is used for enrollment and route discovery, not for shell/log/session bytes. + +The bundled native CLI exercises that path directly: -The shell is installable as a small PWA. Browser notifications are always -delivered by default when permission is granted; on mobile, install the shell -from the browser so the service worker can present notifications through the -platform notification surface while the shell is active. +```bash +devopsdefender noise keygen --cp-url https://app.devopsdefender.com --label laptop +devopsdefender noise recipes --url https:// +devopsdefender noise shell --url https:// --recipe shell +``` + +The CLI uses `DD_ITA_API_KEY` for quote appraisal. `DD_ITA_BASE_URL`, +`DD_ITA_JWKS_URL`, and `DD_ITA_ISSUER` default to Intel Trust Authority's +production endpoints and can be overridden when needed. Local preview/dev runs +without ITA credentials must pass `--insecure-skip-quote-verify` explicitly. + +The web shell should become another client implementation of the same protocol: +it keeps its own paired device identity, asks CP for current routes, and opens +Noise directly to the agent. The existing cookie-auth browser shell remains a +compatibility surface while that client-side Noise implementation lands. ## STONITH diff --git a/apps/README.md b/apps/README.md index d8d70a9..dd3a30a 100644 --- a/apps/README.md +++ b/apps/README.md @@ -175,7 +175,8 @@ inline in two places so both lifecycle points behave identically: | `busybox` | | ✅ | ✅ | | `cloudflared` | ✅ | ✅ | ✅ | | `dd-agent` | | ✅ | ✅ | -| `dd-shell` | | ✅ | ✅ | +| `dd-sessiond` | ✅ | ✅ | ✅ | +| `dd-shell` | ✅ | ✅ | ✅ | | `human-readonly` | | ✅ | | | `dd-management` | ✅ | | | | `podman-static` | | ✅ | ✅ | @@ -190,24 +191,16 @@ Additional examples: is a shell workload recipe, not a `devopsdefender` binary subcommand. - `apps/oracle-readonly`: standalone oracle example with the same scraper and vanity-address metadata; copy this shape into real oracle app repos. -- `apps/confidential-shell`: runs dd-shell with - `DD_SHELL_DIR=/var/lib/easyenclave/data/dd-shell` so read-write PTY - transcript history survives on the workload disk. +- `apps/confidential-shell`: legacy standalone shell workload for deployments + that still run the browser shell and PTY supervisor in one process. - `apps/codex-podman-shell`: alternative read-write shell workload. It exposes - the normal `-shell` label, stores encrypted dd-shell history under - `/var/lib/easyenclave/data/dd-shell`, and advertises a Codex recipe in the - shell UI. The normal shell recipe remains available. Launching Codex starts a - Podman-backed Node container with per-session home, workspace, cache, and tmp - directories supplied by dd-shell. The container installs `@openai/codex` on - first use, so `codex login` can be completed interactively from the browser - terminal. Session scratch is intentionally ephemeral in this first slice; the - encrypted transcript remains under `DD_SHELL_DIR`. Use this instead of - `dd-shell`, not alongside it, unless you give one of them a different - `hostname_label`. - -CP stays slim: just `cloudflared` + `dd-management`. Preview agent VMs run a -small read-only oracle plus agent + podman for CI to prove registration, -scraping, vanity ingress, and dashboards end-to-end. Prod agent VMs use the + the normal `-shell` label and carries an older self-contained Codex recipe + path. New deployments should prefer `dd-sessiond` + `dd-shell`. + +CP stays slim: `cloudflared` + `dd-management` + `dd-sessiond` + `dd-shell`. +Preview agent VMs run a small read-only oracle plus agent + podman for CI to +prove registration, scraping, vanity ingress, and dashboards end-to-end. Prod +agent VMs use the same CPU-only boot shape without demo workloads for now. `dd-local-dogfood` uses that same prod boot chain but is manually managed, sized larger by default, and not relaunched by CI. diff --git a/apps/_infra/local-cp.sh b/apps/_infra/local-cp.sh index 2a89126..57f9264 100755 --- a/apps/_infra/local-cp.sh +++ b/apps/_infra/local-cp.sh @@ -153,6 +153,9 @@ build_config_iso() { DD_OWNER_ID="$EE_OWNER_ID" \ DD_OWNER_KIND="$EE_OWNER_KIND" \ bake "$REPO_ROOT/apps/dd-management/workload.json.tmpl" + DD_SESSIOND_DIR=/tmp/dd-shell \ + DD_SESSIOND_SCRATCH_DIR=/tmp/dd-shell/sessions \ + bake "$REPO_ROOT/apps/dd-sessiond/workload.json.tmpl" DD_DOMAIN="$DD_DOMAIN" \ DD_HOSTNAME="$HOSTNAME" \ DD_ENV="$ENV_LABEL" \ diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index ca152f6..1b9f80a 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -3,16 +3,15 @@ ## Goal Accept one disruptive dogfood redeploy to install a durable session backend. -After that, iterate on desktop/mobile/assistant UI from the centralized fleet -control plane without restarting the dogfood VM or killing active Codex/Claude -sessions. +After that, iterate on desktop/mobile/assistant clients without restarting the +dogfood VM or killing active Codex/Claude sessions. ## Target Shape ```text -browser - -> app.devopsdefender.com fleet UI - -> selected agent session gateway +native/web/mobile client + -> CP route discovery + device enrollment + -> selected agent /noise/ws -> dd-sessiond Unix socket on that VM -> PTY + child process group ``` @@ -28,10 +27,15 @@ Agent VM responsibilities: Control-plane responsibilities: -- Serve the desktop shell UI, mobile UI, and Claude-style assistant view. -- Let users select a fleet agent and attach to sessions through that agent's - session API. -- Ship UI updates via normal CP/web deploys, without touching agent VMs. +- Own device enrollment, revocation, policy, and route discovery. +- Optionally serve static web/PWA assets. +- Never carry shell, log, transcript, or PTY bytes. + +Client responsibilities: + +- Hold a paired device identity. +- Ask CP for current routes and capabilities. +- Connect directly to the selected agent over Noise for runtime data. ## Non-Goals @@ -39,7 +43,9 @@ Control-plane responsibilities: - No hot-swappable UI directory as the primary mechanism. - No fallback in-process PTY mode once `dd-sessiond` is introduced. - No CRIU/checkpoint work in the first implementation. -- No full browser-side Noise handshake in the first implementation. +- No CP relay or mailbox relay for shell/log/session bytes. +- No full browser-side Noise handshake in the first implementation; native CLI + is the first protocol exerciser. ## Phase 1: One Disruptive Dogfood Upgrade @@ -52,8 +58,8 @@ Deliverables: - Add a `dd-sessiond` boot workload for dogfood agents. Implemented in this branch. - Change interactive sessions so `dd-sessiond` owns PTYs. Implemented for `dd-shell` by proxying session create/list/attach/resize/close/replay to sessiond. -- Change `dd-agent` to proxy session APIs to `dd-sessiond`. Implemented with - browser-auth-gated routes for the first cut. +- Change `dd-agent` to proxy session APIs to `dd-sessiond`. Implemented over + paired-device Noise for the first durable client path. - Keep Codex launch working through the session manager. - Preserve persistent disk state, including Codex/npm/login state. @@ -91,57 +97,74 @@ Implementation notes: - Transcript and events are canonical session state, not UI state. - Mobile clients should not resize the canonical PTY by default. -## Phase 3: Centralized Fleet UI +## Phase 3: Client-Side Fleet UI -Move active shell UI development to the CP/fleet dashboard. +Move active shell UI development to clients that use CP only for routes and +agent/device policy. Deliverables: -- Add CP route for agent sessions. +- Add CP route discovery for agent sessions. - UI lists sessions for the selected agent. -- UI can create, attach, input, resize, close, and replay sessions. +- UI can create, attach, input, resize, close, and replay sessions by opening + Noise directly to the selected agent. - Desktop terminal view remains raw PTY-first. - Mobile view can add touch controls, smart sizing, and assistant-style rendering without changing agent-side session ownership. +- The web/PWA interface becomes a client implementation of the same protocol, + not a server-side shell proxy. Acceptance: -- Updating CP UI via PR/release updates the shell/mobile experience. +- Updating static web/PWA assets updates the browser shell/mobile experience. - No dogfood agent restart is needed for UI-only changes. -- Active dogfood Codex sessions survive CP UI updates. +- Active dogfood Codex sessions survive web/client updates. Status: -- Not implemented in the first branch slice. The agent-side session gateway is - present so the CP UI can target it next. +- Not implemented in the first branch slice. The native CLI is present so the + client-side protocol can be tested before replacing the browser shell proxy. ## Phase 4: Auth, Attestation, And Noise Start simple: -- CP issues short-lived scoped session tokens. -- Tokens bind user, agent id, session/action scope, and expiry. -- `dd-agent` validates tokens before proxying to `dd-sessiond`. +- CP enrolls paired device public keys and exposes current routes. +- Agents poll CP for the trusted device set and route/policy freshness. +- Native CLI/desktop/mobile clients use direct `/noise/ws` channels for + session RPCs. Then strengthen: -- Bind session authorization to the attested agent Noise public key. -- CP tracks agent attestation and `noise_pubkey`. -- Desktop/CLI clients can eventually use a direct Noise channel. -- Browser UI can stay token-over-HTTPS until custom browser crypto is worth it. +- Clients verify the agent quote and pin the attested Noise public key. +- Browser/PWA uses the same direct agent Noise path once the browser crypto or + WASM client is in place. + +Implemented first slice: + +- `/health` exposes the quote and Noise public key that clients verify and pin. +- A paired device can send `shell.list_recipes`, `shell.list_sessions`, + `shell.create_session`, `shell.replay_session`, `shell.resize_session`, and + `shell.close_session` JSON requests over Noise. +- `shell.attach_session` returns one JSON ack, then switches the same Noise + session into encrypted raw PTY byte streaming to local `dd-sessiond`. +- CP Noise endpoints reject shell methods because they have no local sessiond + adapter; agent Noise endpoints wire the adapter in. Design rule: ```text remote clients never trust naked tunnel DNS -remote clients trust CP-issued scoped authorization and, later, attested keys +remote clients trust CP-enrolled device identity plus attested agent keys +CP is route/key authority only, never a session data plane dd-sessiond stays local-only dd-agent is the policy/encryption gateway ``` ## Risks And Open Questions -- Whether to keep a minimal agent-local shell UI for emergency access. +- Whether to keep a minimal cookie-auth shell UI only as temporary emergency + compatibility. - How to model recipes so CP UI and `dd-sessiond` agree on available launchers. - How to represent "input requested" events for Codex/Claude without making terminal parsing authoritative. @@ -149,3 +172,4 @@ dd-agent is the policy/encryption gateway passing or exec handoff. - How much of the existing transcript encryption format should move unchanged into `dd-sessiond`. +- Browser Noise implementation choice: pure JS library versus small WASM client. diff --git a/src/agent.rs b/src/agent.rs index a341857..8b9724d 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -196,17 +196,22 @@ pub async fn run() -> Result<()> { std::path::PathBuf::from(noise_gateway::upstream::DEFAULT_EE_AGENT_SOCK), ee_token, )); + let sessiond_http_url = + std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into()); + let sessiond_attach_addr = + std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into()); + let shell = Arc::new(noise_gateway::upstream::Sessiond::new( + sessiond_http_url.clone(), + sessiond_attach_addr.clone(), + )); let ng_state = noise_gateway::State { attest: attestor.clone(), trust, upstream, + shell: Some(shell), }; let gh = gh_oidc::Verifier::new(cfg.common.owner.clone(), "dd-agent".into()); - let sessiond_http_url = - std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into()); - let sessiond_attach_addr = - std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into()); // Seed taint set. Boot-time facts go in now; runtime events // (CustomerOwnerEnabled, CustomerWorkloadDeployed) are appended diff --git a/src/cp.rs b/src/cp.rs index c346aec..f8213e8 100644 --- a/src/cp.rs +++ b/src/cp.rs @@ -353,6 +353,7 @@ pub async fn run() -> Result<()> { attest: attestor.clone(), trust: trust.clone(), upstream, + shell: None, }; let state = St { diff --git a/src/lib.rs b/src/lib.rs index 7568ffc..c4c8b01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod gh_oidc; pub mod html; pub mod ita; pub mod metrics; +pub mod noise_client; pub mod noise_gateway; pub mod oracle; pub mod sessiond; diff --git a/src/main.rs b/src/main.rs index 9482ab1..f131a99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,11 @@ //! DD_MODE=agent devopsdefender # in-VM agent //! DD_MODE=shell devopsdefender # shell web service //! DD_MODE=sessiond devopsdefender # local session supervisor +//! devopsdefender noise ... # native Noise client //! //! (Also accepts `devopsdefender cp` / `devopsdefender agent` for local dev.) -use devopsdefender::{agent, cp, sessiond, shell}; +use devopsdefender::{agent, cp, noise_client, sessiond, shell}; #[tokio::main] async fn main() { @@ -20,8 +21,9 @@ async fn main() { Some("agent") => agent::run().await.map_err(Into::into), Some("shell") => shell::run().await.map_err(Into::into), Some("sessiond") => sessiond::run().await.map_err(Into::into), + Some("noise") | Some("cli") => noise_client::run_cli().await, _ => { - eprintln!("usage: devopsdefender "); + eprintln!("usage: devopsdefender "); eprintln!(" or: DD_MODE= devopsdefender"); std::process::exit(2); } diff --git a/src/noise_client.rs b/src/noise_client.rs new file mode 100644 index 0000000..d8d16ae --- /dev/null +++ b/src/noise_client.rs @@ -0,0 +1,721 @@ +//! Native Noise client for operator shell sessions. +//! +//! This is intentionally small and dependency-light: it speaks the same +//! Noise_IK-over-WebSocket framing as `noise_gateway`, then sends the +//! shell/session RPCs that the gateway forwards to local `dd-sessiond`. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context}; +use base64::Engine as _; +use futures_util::{SinkExt, StreamExt}; +use rand::rngs::OsRng; +use serde_json::Value; +use snow::{Builder, TransportState}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use x25519_dalek::{PublicKey, StaticSecret}; + +const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; +const MAX_NOISE_MSG: usize = 65535; +const ATTACH_CHUNK: usize = 4096; + +type WsStream = WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type WsRead = futures_util::stream::SplitStream; + +pub async fn run_cli() -> anyhow::Result<()> { + let mut args: Vec = std::env::args().skip(2).collect(); + if args.is_empty() || args[0] == "-h" || args[0] == "--help" { + print_usage(); + return Ok(()); + } + + let command = args.remove(0); + match command.as_str() { + "keygen" => { + let opts = parse_opts(args)?; + let key_path = opts.key_path(); + let secret = load_or_create_key(&key_path).await?; + let pubkey = public_hex(&secret); + println!("{pubkey}"); + if let Some(cp_url) = opts.cp_url.as_deref() { + let label = opts.label.unwrap_or_else(default_label); + println!("{}", enrollment_url(cp_url, &pubkey, &label)); + } + } + "pubkey" => { + let opts = parse_opts(args)?; + let secret = load_or_create_key(&opts.key_path()).await?; + println!("{}", public_hex(&secret)); + } + "recipes" => { + let opts = parse_opts(args)?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({"method": "shell.list_recipes"})) + .await?, + )?; + } + "sessions" => { + let opts = parse_opts(args)?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({"method": "shell.list_sessions"})) + .await?, + )?; + } + "create" => { + let opts = parse_opts(args)?; + let mut conn = connect(&opts).await?; + let session = create_session(&mut conn, &opts).await?; + print_json(session)?; + } + "replay" => { + let opts = parse_opts(args)?; + let id = opts + .id + .as_deref() + .ok_or_else(|| anyhow!("replay requires --id"))?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({ + "method": "shell.replay_session", + "id": id, + })) + .await?, + )?; + } + "resize" => { + let opts = parse_opts(args)?; + let id = opts + .id + .as_deref() + .ok_or_else(|| anyhow!("resize requires --id"))?; + let cols = opts.cols.ok_or_else(|| anyhow!("resize requires --cols"))?; + let rows = opts.rows.ok_or_else(|| anyhow!("resize requires --rows"))?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({ + "method": "shell.resize_session", + "id": id, + "cols": cols, + "rows": rows, + })) + .await?, + )?; + } + "close" => { + let opts = parse_opts(args)?; + let id = opts + .id + .as_deref() + .ok_or_else(|| anyhow!("close requires --id"))?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({ + "method": "shell.close_session", + "id": id, + })) + .await?, + )?; + } + "attach" => { + let opts = parse_opts(args)?; + let id = opts + .id + .as_deref() + .ok_or_else(|| anyhow!("attach requires --id"))?; + let conn = connect(&opts).await?; + attach_session(conn, id).await?; + } + "shell" => { + let opts = parse_opts(args)?; + let mut conn = connect(&opts).await?; + let session = create_session(&mut conn, &opts).await?; + let id = session_id(&session)?; + attach_session(conn, &id).await?; + } + "exec" => { + let (opts, cmd) = parse_exec_opts(args)?; + let mut conn = connect(&opts).await?; + print_json( + conn.call(serde_json::json!({ + "method": "exec", + "cmd": cmd, + "timeout_secs": opts.timeout_secs.unwrap_or(60), + })) + .await?, + )?; + } + other => { + anyhow::bail!("unknown noise command `{other}`"); + } + } + Ok(()) +} + +fn print_usage() { + eprintln!( + "usage: + devopsdefender noise keygen [--key PATH] [--cp-url URL] [--label LABEL] + devopsdefender noise pubkey [--key PATH] + devopsdefender noise recipes --url AGENT_URL [--key PATH] + devopsdefender noise sessions --url AGENT_URL [--key PATH] + devopsdefender noise create --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH] + devopsdefender noise replay --url AGENT_URL [--key PATH] --id SESSION_ID + devopsdefender noise resize --url AGENT_URL [--key PATH] --id SESSION_ID --cols N --rows N + devopsdefender noise close --url AGENT_URL [--key PATH] --id SESSION_ID + devopsdefender noise attach --url AGENT_URL [--key PATH] --id SESSION_ID + devopsdefender noise shell --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH] + devopsdefender noise exec --url AGENT_URL [--key PATH] [--timeout SECS] -- CMD [ARG...] + +Quote verification is enabled by default and uses DD_ITA_API_KEY plus optional +DD_ITA_BASE_URL, DD_ITA_JWKS_URL, and DD_ITA_ISSUER. Local dev can pass +--insecure-skip-quote-verify explicitly." + ); +} + +#[derive(Default)] +struct Opts { + url: Option, + key: Option, + cp_url: Option, + label: Option, + recipe: Option, + name: Option, + command: Option, + id: Option, + cols: Option, + rows: Option, + timeout_secs: Option, + insecure_skip_quote_verify: bool, + ita_api_key: Option, + ita_base_url: Option, + ita_jwks_url: Option, + ita_issuer: Option, +} + +impl Opts { + fn key_path(&self) -> PathBuf { + self.key + .clone() + .or_else(|| std::env::var_os("DD_NOISE_CLIENT_KEY").map(PathBuf::from)) + .unwrap_or_else(default_key_path) + } + + fn agent_url(&self) -> anyhow::Result<&str> { + self.url + .as_deref() + .ok_or_else(|| anyhow!("missing --url AGENT_URL")) + } +} + +fn parse_opts(args: Vec) -> anyhow::Result { + let mut opts = Opts::default(); + let mut i = 0; + while i < args.len() { + let key = args[i].as_str(); + i += 1; + let mut take = |name: &str| -> anyhow::Result { + let value = args + .get(i) + .cloned() + .ok_or_else(|| anyhow!("{name} requires a value"))?; + i += 1; + Ok(value) + }; + match key { + "--url" => opts.url = Some(take("--url")?), + "--key" => opts.key = Some(PathBuf::from(take("--key")?)), + "--cp-url" => opts.cp_url = Some(take("--cp-url")?), + "--label" => opts.label = Some(take("--label")?), + "--recipe" => opts.recipe = Some(take("--recipe")?), + "--name" => opts.name = Some(take("--name")?), + "--command" => opts.command = Some(take("--command")?), + "--id" => opts.id = Some(take("--id")?), + "--cols" => opts.cols = Some(parse_u64("--cols", &take("--cols")?)?), + "--rows" => opts.rows = Some(parse_u64("--rows", &take("--rows")?)?), + "--timeout" => opts.timeout_secs = Some(parse_u64("--timeout", &take("--timeout")?)?), + "--ita-api-key" => opts.ita_api_key = Some(take("--ita-api-key")?), + "--ita-base-url" => opts.ita_base_url = Some(take("--ita-base-url")?), + "--ita-jwks-url" => opts.ita_jwks_url = Some(take("--ita-jwks-url")?), + "--ita-issuer" => opts.ita_issuer = Some(take("--ita-issuer")?), + "--insecure-skip-quote-verify" => opts.insecure_skip_quote_verify = true, + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + other => anyhow::bail!("unknown option `{other}`"), + } + } + Ok(opts) +} + +fn parse_exec_opts(args: Vec) -> anyhow::Result<(Opts, Vec)> { + let split = args + .iter() + .position(|arg| arg == "--") + .ok_or_else(|| anyhow!("exec requires `-- CMD [ARG...]`"))?; + let opts = parse_opts(args[..split].to_vec())?; + let cmd = args[split + 1..].to_vec(); + if cmd.is_empty() { + anyhow::bail!("exec requires a command after `--`"); + } + Ok((opts, cmd)) +} + +fn parse_u64(name: &str, value: &str) -> anyhow::Result { + value + .parse() + .with_context(|| format!("{name} must be an unsigned integer")) +} + +struct NoiseConnection { + transport: TransportState, + sink: WsSink, + stream: WsRead, +} + +impl NoiseConnection { + async fn call(&mut self, request: Value) -> anyhow::Result { + let plain = serde_json::to_vec(&request)?; + send_encrypted(&mut self.transport, &mut self.sink, &plain).await?; + let cipher = next_binary(&mut self.stream) + .await? + .ok_or_else(|| anyhow!("Noise websocket closed before response"))?; + let mut out = vec![0u8; cipher.len()]; + let n = self.transport.read_message(&cipher, &mut out)?; + out.truncate(n); + Ok(serde_json::from_slice(&out)?) + } +} + +async fn connect(opts: &Opts) -> anyhow::Result { + let base_url = opts.agent_url()?; + let secret = load_or_create_key(&opts.key_path()).await?; + let server_pubkey = fetch_and_verify_server_pubkey(base_url, opts).await?; + let ws_url = noise_ws_url(base_url); + + let (ws, _response) = connect_async(&ws_url) + .await + .with_context(|| format!("connect {ws_url}"))?; + let (mut sink, mut stream) = ws.split(); + + let mut hs = Builder::new(NOISE_PATTERN.parse()?) + .local_private_key(secret.as_bytes()) + .remote_public_key(&server_pubkey) + .build_initiator()?; + + let mut first = [0u8; MAX_NOISE_MSG]; + let n = hs.write_message(&[], &mut first)?; + sink.send(WsMessage::Binary(first[..n].to_vec().into())) + .await?; + + let second = next_binary(&mut stream) + .await? + .ok_or_else(|| anyhow!("Noise websocket closed during handshake"))?; + let mut payload = [0u8; MAX_NOISE_MSG]; + hs.read_message(&second, &mut payload)?; + + Ok(NoiseConnection { + transport: hs.into_transport_mode()?, + sink, + stream, + }) +} + +async fn fetch_and_verify_server_pubkey(base_url: &str, opts: &Opts) -> anyhow::Result<[u8; 32]> { + let url = health_url(base_url); + let body: Value = reqwest::get(&url) + .await + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url}"))? + .json() + .await + .with_context(|| format!("parse {url}"))?; + let pubkey_hex = body + .pointer("/noise/pubkey_hex") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{url} did not include noise.pubkey_hex"))?; + let quote_b64 = body + .pointer("/noise/quote_b64") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{url} did not include noise.quote_b64"))?; + let bytes = hex::decode(pubkey_hex).context("decode noise.pubkey_hex")?; + if bytes.len() != 32 { + anyhow::bail!( + "noise.pubkey_hex decoded to {} bytes, expected 32", + bytes.len() + ); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + verify_quote_binding(quote_b64, &out, opts).await?; + Ok(out) +} + +async fn verify_quote_binding( + quote_b64: &str, + pubkey: &[u8; 32], + opts: &Opts, +) -> anyhow::Result<()> { + if opts.insecure_skip_quote_verify { + eprintln!("warning: skipping agent TDX quote verification by explicit request"); + return Ok(()); + } + + let api_key = opt_or_env(opts.ita_api_key.as_deref(), "DD_ITA_API_KEY") + .ok_or_else(|| anyhow!("DD_ITA_API_KEY required for quote verification"))?; + let base_url = opt_or_env(opts.ita_base_url.as_deref(), "DD_ITA_BASE_URL") + .unwrap_or_else(|| "https://api.trustauthority.intel.com".into()); + let jwks_url = opt_or_env(opts.ita_jwks_url.as_deref(), "DD_ITA_JWKS_URL") + .unwrap_or_else(|| "https://portal.trustauthority.intel.com/certs".into()); + let issuer = opt_or_env(opts.ita_issuer.as_deref(), "DD_ITA_ISSUER") + .unwrap_or_else(|| "https://portal.trustauthority.intel.com".into()); + + let token = crate::ita::mint(&base_url, &api_key, quote_b64) + .await + .map_err(|e| anyhow!("ITA quote appraisal failed: {e}"))?; + let verifier = crate::ita::Verifier::new(jwks_url, issuer); + let claims = verifier + .verify(&token) + .await + .map_err(|e| anyhow!("ITA token verification failed: {e}"))?; + let report_data = claims + .report_data + .as_deref() + .ok_or_else(|| anyhow!("ITA token missing attester_held_data/report_data"))?; + verify_report_data(report_data, pubkey) +} + +fn opt_or_env(opt: Option<&str>, env: &str) -> Option { + opt.map(str::to_owned).or_else(|| { + std::env::var(env) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }) +} + +fn verify_report_data(report_data: &str, pubkey: &[u8; 32]) -> anyhow::Result<()> { + let bytes = decode_report_data(report_data)?; + match bytes.len() { + 32 if bytes.as_slice() == pubkey => Ok(()), + 64 if bytes[..32] == pubkey[..] && bytes[32..].iter().all(|b| *b == 0) => Ok(()), + 32 | 64 => anyhow::bail!("TDX report_data does not bind expected Noise public key"), + n => anyhow::bail!("TDX report_data decoded to {n} bytes, expected 32 or 64"), + } +} + +fn decode_report_data(report_data: &str) -> anyhow::Result> { + let s = report_data.trim(); + let hexish = s.strip_prefix("0x").unwrap_or(s); + if hexish.len().is_multiple_of(2) && hexish.bytes().all(|b| b.is_ascii_hexdigit()) { + return hex::decode(hexish).context("decode ITA report_data hex"); + } + for engine in [ + &base64::engine::general_purpose::STANDARD, + &base64::engine::general_purpose::STANDARD_NO_PAD, + &base64::engine::general_purpose::URL_SAFE, + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(bytes) = engine.decode(s) { + return Ok(bytes); + } + } + anyhow::bail!("ITA report_data is neither hex nor base64") +} + +async fn create_session(conn: &mut NoiseConnection, opts: &Opts) -> anyhow::Result { + let mut request = serde_json::Map::from_iter([( + "method".to_string(), + Value::String("shell.create_session".into()), + )]); + if let Some(recipe) = opts.recipe.as_deref() { + request.insert("recipe_id".into(), Value::String(recipe.into())); + } + if let Some(name) = opts.name.as_deref() { + request.insert("name".into(), Value::String(name.into())); + } + if let Some(command) = opts.command.as_deref() { + request.insert("command".into(), Value::String(command.into())); + } + conn.call(Value::Object(request)).await +} + +async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": id, + "tail": true, + })) + .await?; + if ack.get("error").is_some() { + anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); + } + + let _raw = RawMode::enter()?; + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut in_buf = [0u8; ATTACH_CHUNK]; + + loop { + tokio::select! { + n = stdin.read(&mut in_buf) => { + let n = n?; + if n == 0 { + break; + } + send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; + } + frame = next_binary(&mut conn.stream) => { + let Some(cipher) = frame? else { + break; + }; + let mut plain = vec![0u8; cipher.len()]; + let n = conn.transport.read_message(&cipher, &mut plain)?; + stdout.write_all(&plain[..n]).await?; + stdout.flush().await?; + } + } + } + Ok(()) +} + +async fn send_encrypted( + transport: &mut TransportState, + sink: &mut WsSink, + plain: &[u8], +) -> anyhow::Result<()> { + let mut cipher = vec![0u8; plain.len() + 16]; + let n = transport.write_message(plain, &mut cipher)?; + cipher.truncate(n); + sink.send(WsMessage::Binary(cipher.into())).await?; + Ok(()) +} + +async fn next_binary(stream: &mut WsRead) -> anyhow::Result>> { + while let Some(msg) = stream.next().await { + match msg? { + WsMessage::Binary(b) => return Ok(Some(b.to_vec())), + WsMessage::Close(_) => return Ok(None), + WsMessage::Text(_) | WsMessage::Ping(_) | WsMessage::Pong(_) | WsMessage::Frame(_) => { + continue + } + } + } + Ok(None) +} + +fn session_id(value: &Value) -> anyhow::Result { + if let Some(error) = value.get("error") { + anyhow::bail!("create failed: {error}"); + } + value + .get("id") + .or_else(|| value.pointer("/session/id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("create response did not include a session id: {value}")) +} + +async fn load_or_create_key(path: &Path) -> anyhow::Result { + match tokio::fs::read(path).await { + Ok(bytes) if bytes.len() == 32 => { + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(StaticSecret::from(key)) + } + Ok(bytes) => anyhow::bail!("{} is {} bytes, expected 32", path.display(), bytes.len()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let secret = StaticSecret::random_from_rng(OsRng); + persist_key(path, secret.as_bytes()).await?; + Ok(secret) + } + Err(e) => Err(e).with_context(|| format!("read {}", path.display())), + } +} + +async fn persist_key(path: &Path, bytes: &[u8; 32]) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let tmp = path.with_extension("key.tmp"); + tokio::fs::write(&tmp, bytes).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).await?; + } + tokio::fs::rename(&tmp, path).await?; + Ok(()) +} + +fn public_hex(secret: &StaticSecret) -> String { + hex::encode(PublicKey::from(secret).as_bytes()) +} + +fn default_key_path() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home) + .join(".config") + .join("devopsdefender") + .join("noise.key"); + } + PathBuf::from(".devopsdefender-noise.key") +} + +fn default_label() -> String { + std::env::var("HOSTNAME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "native-cli".into()) +} + +fn enrollment_url(cp_url: &str, pubkey_hex: &str, label: &str) -> String { + format!( + "{}/admin/enroll?pubkey={}&label={}", + normalize_http_base(cp_url), + pubkey_hex, + urlencoding::encode(label) + ) +} + +fn health_url(base_url: &str) -> String { + format!("{}/health", normalize_http_base(base_url)) +} + +fn noise_ws_url(base_url: &str) -> String { + let base = normalize_http_base(base_url); + let ws_base = if let Some(rest) = base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = base.strip_prefix("http://") { + format!("ws://{rest}") + } else { + base + }; + format!("{}/noise/ws", ws_base.trim_end_matches('/')) +} + +fn normalize_http_base(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + } +} + +fn print_json(value: Value) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} + +struct RawMode { + #[cfg(unix)] + original: Option, +} + +impl RawMode { + fn enter() -> anyhow::Result { + #[cfg(unix)] + { + if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { + return Ok(Self { original: None }); + } + let mut original = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()).context("tcgetattr"); + } + let original = unsafe { original.assume_init() }; + let mut raw = original; + unsafe { libc::cfmakeraw(&mut raw) }; + if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { + return Err(std::io::Error::last_os_error()).context("tcsetattr raw"); + } + Ok(Self { + original: Some(original), + }) + } + #[cfg(not(unix))] + { + Ok(Self {}) + } + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(original) = &self.original { + let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_urls_from_bare_host() { + assert_eq!( + health_url("agent.example.com/"), + "https://agent.example.com/health" + ); + assert_eq!( + noise_ws_url("agent.example.com/"), + "wss://agent.example.com/noise/ws" + ); + } + + #[test] + fn keeps_local_http_scheme() { + assert_eq!( + health_url("http://127.0.0.1:8080"), + "http://127.0.0.1:8080/health" + ); + assert_eq!( + noise_ws_url("http://127.0.0.1:8080"), + "ws://127.0.0.1:8080/noise/ws" + ); + } + + #[test] + fn enrollment_url_encodes_label() { + assert_eq!( + enrollment_url("https://cp.example.com/", "abcd", "me laptop"), + "https://cp.example.com/admin/enroll?pubkey=abcd&label=me%20laptop" + ); + } + + #[test] + fn report_data_accepts_64_byte_hex_binding() { + let mut report = [0u8; 64]; + report[..32].fill(7); + let pubkey = [7u8; 32]; + verify_report_data(&hex::encode(report), &pubkey).unwrap(); + } + + #[test] + fn report_data_rejects_wrong_key() { + let mut report = [0u8; 64]; + report[..32].fill(7); + let pubkey = [8u8; 32]; + assert!(verify_report_data(&hex::encode(report), &pubkey).is_err()); + } + + #[test] + fn report_data_accepts_base64_binding() { + let mut report = [0u8; 64]; + report[..32].fill(9); + let pubkey = [9u8; 32]; + let encoded = base64::engine::general_purpose::STANDARD.encode(report); + verify_report_data(&encoded, &pubkey).unwrap(); + } +} diff --git a/src/noise_gateway/allowlist.rs b/src/noise_gateway/allowlist.rs index 816607b..3155043 100644 --- a/src/noise_gateway/allowlist.rs +++ b/src/noise_gateway/allowlist.rs @@ -15,6 +15,13 @@ pub enum Method { Health, List, Logs, + ShellAttachSession, + ShellCloseSession, + ShellCreateSession, + ShellListRecipes, + ShellListSessions, + ShellReplaySession, + ShellResizeSession, } impl Method { @@ -26,6 +33,13 @@ impl Method { Self::Health => "health", Self::List => "list", Self::Logs => "logs", + Self::ShellAttachSession => "shell.attach_session", + Self::ShellCloseSession => "shell.close_session", + Self::ShellCreateSession => "shell.create_session", + Self::ShellListRecipes => "shell.list_recipes", + Self::ShellListSessions => "shell.list_sessions", + Self::ShellReplaySession => "shell.replay_session", + Self::ShellResizeSession => "shell.resize_session", } } } @@ -45,6 +59,13 @@ pub fn classify(raw: &serde_json::Value) -> Result { "health" => Ok(Method::Health), "list" => Ok(Method::List), "logs" => Ok(Method::Logs), + "shell.attach_session" => Ok(Method::ShellAttachSession), + "shell.close_session" => Ok(Method::ShellCloseSession), + "shell.create_session" => Ok(Method::ShellCreateSession), + "shell.list_recipes" => Ok(Method::ShellListRecipes), + "shell.list_sessions" => Ok(Method::ShellListSessions), + "shell.replay_session" => Ok(Method::ShellReplaySession), + "shell.resize_session" => Ok(Method::ShellResizeSession), "deploy" => Err(ClassifyError::Disallowed("deploy".into())), other => Err(ClassifyError::Unknown(other.into())), } @@ -76,6 +97,12 @@ mod tests { assert_eq!(r.unwrap(), Method::Exec); } + #[test] + fn shell_attach_allowed() { + let r = classify(&serde_json::json!({"method": "shell.attach_session", "id": "abc"})); + assert_eq!(r.unwrap(), Method::ShellAttachSession); + } + #[test] fn unknown_rejected() { let r = classify(&serde_json::json!({"method": "steal"})); diff --git a/src/noise_gateway/mod.rs b/src/noise_gateway/mod.rs index be37377..fc45976 100644 --- a/src/noise_gateway/mod.rs +++ b/src/noise_gateway/mod.rs @@ -45,6 +45,7 @@ pub struct State { pub attest: Arc, pub trust: TrustHandle, pub upstream: Arc, + pub shell: Option>, } pub fn router(state: State) -> Router { diff --git a/src/noise_gateway/noise.rs b/src/noise_gateway/noise.rs index 2d82f2d..887c1f7 100644 --- a/src/noise_gateway/noise.rs +++ b/src/noise_gateway/noise.rs @@ -26,7 +26,7 @@ use axum::routing::get; use axum::Router; use futures_util::StreamExt; use snow::{Builder, HandshakeState, TransportState}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use super::{allowlist, State as AppState}; @@ -122,6 +122,55 @@ async fn handle(mut socket: WebSocket, state: AppState) -> anyhow::Result<()> { } } } + Ok(allowlist::Method::ShellAttachSession) => { + let Some(shell) = &state.shell else { + let resp = serde_json::json!({ + "error": "shell_unavailable", + "detail": "this Noise endpoint is not connected to sessiond", + }); + send_encrypted_json(&mut transport, &mut socket, &resp).await?; + continue; + }; + match shell.attach_stream(request).await { + Ok((ack, stream)) => { + send_encrypted_json(&mut transport, &mut socket, &ack).await?; + bridge_attach(&mut transport, &mut socket, stream).await?; + return Ok(()); + } + Err(e) => { + let resp = serde_json::json!({ + "error": "shell_attach_failed", + "detail": e.to_string(), + }); + send_encrypted_json(&mut transport, &mut socket, &resp).await?; + continue; + } + } + } + Ok( + allowlist::Method::ShellCloseSession + | allowlist::Method::ShellCreateSession + | allowlist::Method::ShellListRecipes + | allowlist::Method::ShellListSessions + | allowlist::Method::ShellReplaySession + | allowlist::Method::ShellResizeSession, + ) => { + let Some(shell) = &state.shell else { + let resp = serde_json::json!({ + "error": "shell_unavailable", + "detail": "this Noise endpoint is not connected to sessiond", + }); + send_encrypted_json(&mut transport, &mut socket, &resp).await?; + continue; + }; + let response = shell.call(request).await.unwrap_or_else(|e| { + serde_json::json!({ + "error": "shell_failed", + "detail": e.to_string(), + }) + }); + send_encrypted_json(&mut transport, &mut socket, &response).await?; + } Ok(_method) => { let response = state.upstream.call(request).await.unwrap_or_else(|e| { serde_json::json!({ @@ -172,28 +221,28 @@ async fn send_encrypted_bytes( async fn bridge_attach( transport: &mut TransportState, socket: &mut WebSocket, - ee_stream: tokio::net::UnixStream, + stream: impl AsyncRead + AsyncWrite + Unpin, ) -> anyhow::Result<()> { - let (mut ee_rd, mut ee_wr) = ee_stream.into_split(); + let (mut stream_rd, mut stream_wr) = tokio::io::split(stream); let mut ee_buf = [0u8; ATTACH_CHUNK]; loop { tokio::select! { biased; - // EE → client: raw PTY bytes, encrypted and forwarded. - n = ee_rd.read(&mut ee_buf) => { + // Upstream → client: raw PTY bytes, encrypted and forwarded. + n = stream_rd.read(&mut ee_buf) => { match n? { - 0 => break, // EE closed (shell exited) + 0 => break, // Upstream closed (shell exited) n => send_encrypted_bytes(transport, socket, &ee_buf[..n]).await?, } } - // Client → EE: decrypt and write stdin. + // Client → upstream: decrypt and write stdin. frame = next_binary(socket) => { match frame? { Some(cipher) => { let mut plain = vec![0u8; cipher.len()]; let n = transport.read_message(&cipher, &mut plain)?; - ee_wr.write_all(&plain[..n]).await?; + stream_wr.write_all(&plain[..n]).await?; } None => break, // WS closed } diff --git a/src/noise_gateway/upstream.rs b/src/noise_gateway/upstream.rs index 066b9b6..607e0ef 100644 --- a/src/noise_gateway/upstream.rs +++ b/src/noise_gateway/upstream.rs @@ -11,9 +11,10 @@ //! and the caller is responsible for byte-bridging. use std::path::PathBuf; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; +use tokio::net::{TcpStream, UnixStream}; pub const DEFAULT_EE_AGENT_SOCK: &str = "/var/lib/easyenclave/agent.sock"; @@ -94,6 +95,140 @@ impl EeAgent { } } +pub struct Sessiond { + http_url: String, + attach_addr: String, + http: reqwest::Client, +} + +impl Sessiond { + pub fn new(http_url: String, attach_addr: String) -> Self { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .no_hickory_dns() + .build() + .unwrap_or_else(|_| crate::system_http_client()); + Self { + http_url, + attach_addr, + http, + } + } + + pub async fn call(&self, req: serde_json::Value) -> anyhow::Result { + let method = req + .get("method") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("shell request missing method"))?; + match method { + "shell.list_recipes" => self.get_json("/api/recipes").await, + "shell.list_sessions" => self.get_json("/api/sessions").await, + "shell.create_session" => self.post_json("/api/sessions", &session_body(req)).await, + "shell.replay_session" => { + let id = required_str(&req, "id")?; + self.get_json(&format!("/api/sessions/{id}/replay")).await + } + "shell.resize_session" => { + let id = required_str(&req, "id")?; + self.post_empty( + &format!("/api/sessions/{id}/resize"), + &serde_json::json!({ + "cols": req.get("cols").and_then(|v| v.as_u64()).unwrap_or(80), + "rows": req.get("rows").and_then(|v| v.as_u64()).unwrap_or(24), + }), + ) + .await + } + "shell.close_session" => { + let id = required_str(&req, "id")?; + self.post_empty(&format!("/api/sessions/{id}/close"), &serde_json::json!({})) + .await + } + other => anyhow::bail!("unsupported shell method: {other}"), + } + } + + pub async fn attach_stream( + &self, + req: serde_json::Value, + ) -> anyhow::Result<(serde_json::Value, TcpStream)> { + let id = required_str(&req, "id")?; + let tail = req.get("tail").and_then(|v| v.as_bool()).unwrap_or(true); + let mut stream = TcpStream::connect(&self.attach_addr).await?; + let tail_arg = if tail { "tail" } else { "notail" }; + stream + .write_all(format!("{id} {tail_arg}\n").as_bytes()) + .await?; + Ok(( + serde_json::json!({ + "ok": true, + "method": "shell.attach_session", + "id": id, + "tail": tail, + }), + stream, + )) + } + + async fn get_json(&self, path: &str) -> anyhow::Result { + let resp = self.http.get(self.url(path)).send().await?; + decode_json(path, resp).await + } + + async fn post_json( + &self, + path: &str, + body: &serde_json::Value, + ) -> anyhow::Result { + let resp = self.http.post(self.url(path)).json(body).send().await?; + decode_json(path, resp).await + } + + async fn post_empty( + &self, + path: &str, + body: &serde_json::Value, + ) -> anyhow::Result { + let resp = self.http.post(self.url(path)).json(body).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("sessiond {path}: HTTP {status}: {body}"); + } + Ok(serde_json::json!({"ok": true})) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.http_url.trim_end_matches('/'), path) + } +} + +fn session_body(req: serde_json::Value) -> serde_json::Value { + let mut body = serde_json::Map::new(); + for key in ["name", "recipe_id", "command", "cwd"] { + if let Some(value) = req.get(key) { + body.insert(key.to_string(), value.clone()); + } + } + serde_json::Value::Object(body) +} + +fn required_str<'a>(req: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a str> { + req.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("shell request missing `{key}`")) +} + +async fn decode_json(path: &str, resp: reqwest::Response) -> anyhow::Result { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("sessiond {path}: HTTP {status}: {body}"); + } + Ok(resp.json().await?) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/shell.rs b/src/shell.rs index f1548ef..c799614 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -2,17 +2,10 @@ //! //! One process per VM, multiple reconnectable PTY sessions, read-only workload //! terminals, and encrypted append-only transcripts on disk. -#![allow(dead_code, unused_imports)] - -use std::cmp::Reverse; -use std::collections::{HashMap, VecDeque}; -use std::fs::File as StdFile; -use std::os::fd::{AsRawFd, FromRawFd}; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::process::Stdio; + +use std::collections::HashMap; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Path as AxPath, Query, State}; @@ -21,20 +14,11 @@ use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::Engine as _; -use chacha20poly1305::aead::{Aead, KeyInit}; -use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use futures_util::{SinkExt, StreamExt}; -use rand::RngCore; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use tokio::fs::File as TokioFile; -use tokio::fs::OpenOptions; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::process::{Child, Command}; -use tokio::sync::{broadcast, Mutex, RwLock}; -use uuid::Uuid; use crate::ee::Ee; use crate::error::{Error, Result}; @@ -44,104 +28,9 @@ use crate::taint::IntegrityState; use crate::units::{self, AgentMode, ManagedUnit, UnitKind}; const DEFAULT_PORT: u16 = 7681; -const DEFAULT_DIR: &str = "/var/lib/devopsdefender/shell"; -const RING_LIMIT: usize = 256 * 1024; -const CODEX_PODMAN_RECIPE: &str = r#"#!/var/lib/easyenclave/bin/busybox sh -set -eu -BB=/var/lib/easyenclave/bin/busybox -BIN=/var/lib/easyenclave/bin -PODMAN=$BIN/podman -SESSION_ID=${DD_SESSION_ID:-manual-$$} -SESSION_DIR=${DD_SESSION_DIR:-/var/lib/easyenclave/data/dd-shell/sessions/$SESSION_ID} -WORKSPACE=${DD_WORKSPACE:-$SESSION_DIR/workspace} -HOME_DIR=${DD_HOME:-$SESSION_DIR/home} -CACHE_DIR=${DD_CACHE:-$SESSION_DIR/cache} -TMP_DIR=${TMPDIR:-$SESSION_DIR/tmp} -until [ -x "$PODMAN" ]; do echo "codex-podman: waiting for podman"; $BB sleep 2; done -$BB mkdir -p "$HOME_DIR" "$WORKSPACE" "$CACHE_DIR" "$TMP_DIR" -$BB chmod 1777 "$TMP_DIR" -SAFE_SESSION=$(printf '%s' "$SESSION_ID" | $BB tr -c 'A-Za-z0-9_.-' '-') -exec "$PODMAN" run --rm --replace -it --pull=missing \ - --cgroups=disabled \ - --network=host \ - --name "codex-shell-$SAFE_SESSION" \ - -e HOME=/root \ - -e TERM=xterm-256color \ - -e COLORTERM=truecolor \ - -e NPM_CONFIG_PREFIX=/root/.npm-global \ - -v "$HOME_DIR:/root" \ - -v "$WORKSPACE:/workspace" \ - -v "$CACHE_DIR:/root/.cache" \ - -v "$TMP_DIR:/tmp" \ - -w /workspace \ - docker.io/library/node:22-bookworm \ - sh -lc 'set -e; mkdir -p "$HOME/.npm-global/bin" "$HOME/.local/bin"; printf "%s\n" "export NPM_CONFIG_PREFIX=\${NPM_CONFIG_PREFIX:-\$HOME/.npm-global}" "export PATH=\"\$HOME/.npm-global/bin:\$HOME/.local/bin:\$PATH\"" > "$HOME/.bashrc"; printf "%s\n" "[ -r \"\$HOME/.bashrc\" ] && . \"\$HOME/.bashrc\"" > "$HOME/.bash_profile"; export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH"; if ! command -v codex >/dev/null 2>&1; then npm install -g @openai/codex; fi; exec bash -l' -"#; -const PODMAN_UBUNTU_RECIPE: &str = r#"#!/var/lib/easyenclave/bin/busybox sh -set -eu -BB=/var/lib/easyenclave/bin/busybox -BIN=/var/lib/easyenclave/bin -PODMAN=$BIN/podman -SESSION_ID=${DD_SESSION_ID:-manual-$$} -SESSION_DIR=${DD_SESSION_DIR:-/var/lib/easyenclave/data/dd-shell/sessions/$SESSION_ID} -WORKSPACE=${DD_WORKSPACE:-$SESSION_DIR/workspace} -HOME_DIR=${DD_HOME:-$SESSION_DIR/home} -CACHE_DIR=${DD_CACHE:-$SESSION_DIR/cache} -TMP_DIR=${TMPDIR:-$SESSION_DIR/tmp} -until [ -x "$PODMAN" ]; do echo "podman-ubuntu: waiting for podman"; $BB sleep 2; done -$BB mkdir -p "$HOME_DIR" "$WORKSPACE" "$CACHE_DIR" "$TMP_DIR" -$BB chmod 1777 "$TMP_DIR" -SAFE_SESSION=$(printf '%s' "$SESSION_ID" | $BB tr -c 'A-Za-z0-9_.-' '-') -exec "$PODMAN" run --rm --replace -it --pull=missing \ - --cgroups=disabled \ - --network=host \ - --name "ubuntu-shell-$SAFE_SESSION" \ - -e HOME=/root \ - -e TERM=xterm-256color \ - -e COLORTERM=truecolor \ - -v "$HOME_DIR:/root" \ - -v "$WORKSPACE:/workspace" \ - -v "$CACHE_DIR:/root/.cache" \ - -v "$TMP_DIR:/tmp" \ - -w /workspace \ - docker.io/library/ubuntu:24.04 \ - bash -l -"#; -const PODMAN_ALPINE_RECIPE: &str = r#"#!/var/lib/easyenclave/bin/busybox sh -set -eu -BB=/var/lib/easyenclave/bin/busybox -BIN=/var/lib/easyenclave/bin -PODMAN=$BIN/podman -SESSION_ID=${DD_SESSION_ID:-manual-$$} -SESSION_DIR=${DD_SESSION_DIR:-/var/lib/easyenclave/data/dd-shell/sessions/$SESSION_ID} -WORKSPACE=${DD_WORKSPACE:-$SESSION_DIR/workspace} -HOME_DIR=${DD_HOME:-$SESSION_DIR/home} -CACHE_DIR=${DD_CACHE:-$SESSION_DIR/cache} -TMP_DIR=${TMPDIR:-$SESSION_DIR/tmp} -until [ -x "$PODMAN" ]; do echo "podman-alpine: waiting for podman"; $BB sleep 2; done -$BB mkdir -p "$HOME_DIR" "$WORKSPACE" "$CACHE_DIR" "$TMP_DIR" -$BB chmod 1777 "$TMP_DIR" -SAFE_SESSION=$(printf '%s' "$SESSION_ID" | $BB tr -c 'A-Za-z0-9_.-' '-') -exec "$PODMAN" run --rm --replace -it --pull=missing \ - --cgroups=disabled \ - --network=host \ - --name "alpine-shell-$SAFE_SESSION" \ - -e HOME=/root \ - -e TERM=xterm-256color \ - -e COLORTERM=truecolor \ - -v "$HOME_DIR:/root" \ - -v "$WORKSPACE:/workspace" \ - -v "$CACHE_DIR:/root/.cache" \ - -v "$TMP_DIR:/tmp" \ - -w /workspace \ - docker.io/library/alpine:3.20 \ - /bin/sh -"#; #[derive(Clone)] struct App { - sessions: Arc>>>, - store: TranscriptStore, ee: Arc, http: reqwest::Client, agent_api: String, @@ -150,75 +39,6 @@ struct App { owner: crate::gh_oidc::Principal, auth: crate::auth::AuthConfig, hostname: String, - recipes: Arc>, - scratch_root: PathBuf, -} - -struct Session { - meta: RwLock, - input: Mutex, - master_fd: i32, - child: Mutex, - pgid: i32, - tx: broadcast::Sender>, - ring: Mutex>, - scratch_dir: Option, - cleanup_scratch_on_exit: bool, -} - -#[derive(Clone, Serialize)] -struct SessionMeta { - id: String, - name: String, - recipe_id: String, - recipe_title: String, - workspace_policy: WorkspacePolicy, - command: String, - cwd: String, - terminal_mode: TerminalMode, - integrity_state: IntegrityState, - integrity_reason: &'static str, - created_at: i64, - updated_at: i64, - status: SessionStatus, - exit_code: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "snake_case")] -enum TerminalMode { - ReadWrite, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "snake_case")] -enum SessionStatus { - Running, - Exited, -} - -#[derive(Clone, Serialize)] -struct Recipe { - id: String, - title: String, - description: String, - command: String, - cwd: String, - workspace_policy: WorkspacePolicy, -} - -struct RecipeSeed { - id: &'static str, - title: &'static str, - description: &'static str, - script_name: &'static str, - script: &'static str, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "snake_case")] -enum WorkspacePolicy { - EphemeralScratch, } #[derive(Deserialize, Serialize)] @@ -229,30 +49,12 @@ struct CreateSession { cwd: Option, } -#[derive(Serialize)] -struct CreateSessionResponse { - id: String, -} - #[derive(Deserialize, Serialize)] struct ResizeSession { cols: u16, rows: u16, } -#[derive(Clone)] -struct TranscriptStore { - dir: PathBuf, - key: [u8; 32], -} - -#[derive(Serialize, Deserialize)] -struct TranscriptRecord { - ts: i64, - kind: String, - data_b64: String, -} - #[derive(Serialize)] struct ReplayResponse { id: String, @@ -286,11 +88,6 @@ pub async fn run() -> Result<()> { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_PORT); - let dir = std::env::var("DD_SHELL_DIR").unwrap_or_else(|_| DEFAULT_DIR.into()); - let requested_shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let scratch_root = std::env::var("DD_SHELL_SCRATCH_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(&dir).join("sessions")); let ee_socket = std::env::var("DD_SHELL_EE_SOCKET") .unwrap_or_else(|_| "/var/lib/easyenclave/agent.sock".into()); let agent_api = std::env::var("DD_SHELL_AGENT_API_URL") @@ -299,18 +96,8 @@ pub async fn run() -> Result<()> { std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into()); let sessiond_attach_addr = std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into()); - let shell_dir = PathBuf::from(&dir); - let store = TranscriptStore::new(shell_dir.clone()).await?; - tokio::fs::create_dir_all(&scratch_root).await?; - set_private_dir_permissions(&scratch_root).await?; - let recipe_dir = shell_dir.join("recipes"); - let default_shell = install_default_shell_command(&recipe_dir, &requested_shell).await?; - let recipe_scripts = install_builtin_recipe_scripts(&recipe_dir).await?; - let recipes = Arc::new(load_recipes(&default_shell, recipe_scripts)); let app_state = App { - sessions: Arc::new(RwLock::new(HashMap::new())), - store, ee: Arc::new(Ee::new(ee_socket)), http: reqwest::Client::builder() .timeout(Duration::from_secs(3)) @@ -323,8 +110,6 @@ pub async fn run() -> Result<()> { owner: common.owner, auth, hostname, - recipes, - scratch_root, }; let app = Router::new() @@ -525,283 +310,6 @@ async fn create_session( sessiond_post(&app, "/api/sessions", &req).await.map(Json) } -fn select_recipe(app: &App, recipe_id: Option<&str>, command: Option) -> Result { - if let Some(command) = command { - let command = command.trim(); - if command.is_empty() { - return Err(Error::BadRequest("command must not be empty".into())); - } - return Ok(Recipe { - id: "custom".into(), - title: "Custom".into(), - description: "Custom command".into(), - command: command.into(), - cwd: "/".into(), - workspace_policy: WorkspacePolicy::EphemeralScratch, - }); - } - - let id = recipe_id.unwrap_or("shell"); - app.recipes - .iter() - .find(|recipe| recipe.id == id) - .cloned() - .ok_or_else(|| Error::BadRequest(format!("unknown recipe: {id}"))) -} - -async fn install_default_shell_command(dir: &Path, requested_shell: &str) -> Result { - if executable_exists(requested_shell).await { - return Ok(requested_shell.into()); - } - if executable_exists("/bin/sh").await { - return Ok("/bin/sh".into()); - } - if executable_exists("/var/lib/easyenclave/bin/busybox").await { - tokio::fs::create_dir_all(dir).await?; - set_private_dir_permissions(dir).await?; - let path = dir.join("plain-shell"); - tokio::fs::write( - &path, - "#!/var/lib/easyenclave/bin/busybox sh\nexec /var/lib/easyenclave/bin/busybox sh\n", - ) - .await?; - set_private_file_permissions(&path).await?; - return Ok(path.display().to_string()); - } - Ok(requested_shell.into()) -} - -async fn executable_exists(path: &str) -> bool { - match tokio::fs::metadata(path).await { - Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0, - Err(_) => false, - } -} - -async fn install_builtin_recipe_scripts(dir: &Path) -> Result> { - tokio::fs::create_dir_all(dir).await?; - set_private_dir_permissions(dir).await?; - - let mut recipes = Vec::new(); - for seed in builtin_recipe_seeds() { - let path = dir.join(seed.script_name); - tokio::fs::write(&path, seed.script).await?; - set_private_file_permissions(&path).await?; - recipes.push(Recipe { - id: seed.id.into(), - title: seed.title.into(), - description: seed.description.into(), - command: path.display().to_string(), - cwd: "/".into(), - workspace_policy: WorkspacePolicy::EphemeralScratch, - }); - } - Ok(recipes) -} - -fn load_recipes(default_shell: &str, mut builtin_recipes: Vec) -> Vec { - let mut recipes = vec![Recipe { - id: "shell".into(), - title: "Shell".into(), - description: "Plain interactive shell with encrypted transcript history".into(), - command: default_shell.into(), - cwd: "/".into(), - workspace_policy: WorkspacePolicy::EphemeralScratch, - }]; - recipes.append(&mut builtin_recipes); - - if let Ok(command) = std::env::var("DD_SHELL_CODEX_COMMAND") { - let command = command.trim(); - if !command.is_empty() { - upsert_recipe( - &mut recipes, - Recipe { - id: "codex-podman".into(), - title: "Codex".into(), - description: "Podman-backed Codex development session".into(), - command: command.into(), - cwd: "/".into(), - workspace_policy: WorkspacePolicy::EphemeralScratch, - }, - ); - } - } - - recipes -} - -fn upsert_recipe(recipes: &mut Vec, recipe: Recipe) { - if let Some(existing) = recipes.iter_mut().find(|r| r.id == recipe.id) { - *existing = recipe; - } else { - recipes.push(recipe); - } -} - -fn builtin_recipe_seeds() -> Vec { - vec![ - RecipeSeed { - id: "codex-podman", - title: "Codex", - description: "Podman-backed Codex development session", - script_name: "codex-podman", - script: CODEX_PODMAN_RECIPE, - }, - RecipeSeed { - id: "podman-ubuntu", - title: "Ubuntu", - description: "Podman Ubuntu 24.04 shell", - script_name: "podman-ubuntu", - script: PODMAN_UBUNTU_RECIPE, - }, - RecipeSeed { - id: "podman-alpine", - title: "Alpine", - description: "Podman Alpine shell", - script_name: "podman-alpine", - script: PODMAN_ALPINE_RECIPE, - }, - ] -} - -async fn prepare_scratch_dir( - app: &App, - id: &str, - policy: &WorkspacePolicy, -) -> Result> { - match policy { - WorkspacePolicy::EphemeralScratch => { - let root = app.scratch_root.join(id); - tokio::fs::create_dir_all(&root).await?; - set_private_dir_permissions(&root).await?; - for name in ["workspace", "home", "containers", "cache", "tmp"] { - let path = root.join(name); - tokio::fs::create_dir_all(&path).await?; - set_private_dir_permissions(&path).await?; - } - Ok(Some(root)) - } - } -} - -async fn set_private_dir_permissions(path: &Path) -> Result<()> { - let permissions = std::fs::Permissions::from_mode(0o700); - tokio::fs::set_permissions(path, permissions).await?; - Ok(()) -} - -async fn set_private_file_permissions(path: &Path) -> Result<()> { - let permissions = std::fs::Permissions::from_mode(0o700); - tokio::fs::set_permissions(path, permissions).await?; - Ok(()) -} - -fn session_env(id: &str, scratch_dir: Option<&Path>) -> Vec<(String, String)> { - let mut env = vec![("DD_SESSION_ID".into(), id.into())]; - if let Some(root) = scratch_dir { - env.push(("DD_SESSION_DIR".into(), root.display().to_string())); - env.push(( - "DD_WORKSPACE".into(), - root.join("workspace").display().to_string(), - )); - env.push(("DD_HOME".into(), root.join("home").display().to_string())); - env.push(( - "DD_CONTAINER_ROOT".into(), - root.join("containers").display().to_string(), - )); - env.push(("DD_CACHE".into(), root.join("cache").display().to_string())); - env.push(("TMPDIR".into(), root.join("tmp").display().to_string())); - } - env -} - -fn session_name(recipe: &Recipe, id: &str) -> String { - format!("{}-{}", recipe.id, &id[..8]) -} - -fn spawn_pty( - command: &str, - cwd: &str, - env_vars: &[(String, String)], -) -> Result<(Child, TokioFile, TokioFile, i32)> { - let mut master = -1; - let mut slave = -1; - let winsize = libc::winsize { - ws_row: 24, - ws_col: 80, - ws_xpixel: 0, - ws_ypixel: 0, - }; - let open_rc = unsafe { - libc::openpty( - &mut master, - &mut slave, - std::ptr::null_mut(), - std::ptr::null(), - &winsize, - ) - }; - if open_rc != 0 { - return Err(Error::Internal(format!( - "openpty: {}", - std::io::Error::last_os_error() - ))); - } - - let master = unsafe { StdFile::from_raw_fd(master) }; - let output = TokioFile::from_std( - master - .try_clone() - .map_err(|e| Error::Internal(format!("pty master clone: {e}")))?, - ); - let input = TokioFile::from_std(master); - let slave = unsafe { StdFile::from_raw_fd(slave) }; - let slave_fd = slave.as_raw_fd(); - - let dup_slave = || -> std::io::Result { - let fd = unsafe { libc::dup(slave_fd) }; - if fd < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(unsafe { StdFile::from_raw_fd(fd) }) - } - }; - - let mut cmd = Command::new(command); - cmd.current_dir(cwd) - .env("TERM", "xterm-256color") - .env("COLORTERM", "truecolor") - .stdin(Stdio::from( - dup_slave().map_err(|e| Error::Internal(format!("pty stdin dup: {e}")))?, - )) - .stdout(Stdio::from( - dup_slave().map_err(|e| Error::Internal(format!("pty stdout dup: {e}")))?, - )) - .stderr(Stdio::from( - dup_slave().map_err(|e| Error::Internal(format!("pty stderr dup: {e}")))?, - )); - for (key, value) in env_vars { - cmd.env(key, value); - } - unsafe { - cmd.pre_exec(move || { - if libc::setsid() < 0 { - return Err(std::io::Error::last_os_error()); - } - if libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) < 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - let child = cmd - .spawn() - .map_err(|e| Error::BadRequest(format!("spawn {command}: {e}")))?; - let pgid = child.id().map(|pid| pid as i32).unwrap_or_default(); - drop(slave); - Ok((child, output, input, pgid)) -} - async fn replay_session( State(app): State, headers: HeaderMap, @@ -1092,31 +600,6 @@ async fn close_session( sessiond_post_empty(&app, &format!("/api/sessions/{id}/close")).await } -fn terminate_process_group(id: String, pgid: i32) { - if pgid <= 0 { - return; - } - tokio::spawn(async move { - for (signal, delay) in [ - (libc::SIGHUP, Duration::from_millis(250)), - (libc::SIGTERM, Duration::from_millis(1500)), - (libc::SIGKILL, Duration::ZERO), - ] { - let rc = unsafe { libc::kill(-pgid, signal) }; - if rc != 0 { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() != Some(libc::ESRCH) { - eprintln!("dd-shell: signal {signal} for {id}: {err}"); - } - break; - } - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - } - }); -} - fn workload_log_bytes(logs: &serde_json::Value) -> Vec { let mut out = Vec::new(); if let Some(lines) = logs["lines"].as_array() { @@ -1209,209 +692,6 @@ async fn attach( Ok(()) } -fn spawn_reader(store: TranscriptStore, session: Arc, mut reader: R, kind: &'static str) -where - R: tokio::io::AsyncRead + Unpin + Send + 'static, -{ - tokio::spawn(async move { - let id = session.meta.read().await.id.clone(); - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf).await { - Ok(0) => break, - Ok(n) => { - let bytes = buf[..n].to_vec(); - push_ring(&session, &bytes).await; - let _ = session.tx.send(bytes.clone()); - if let Err(e) = store.append_bytes(&id, kind, &bytes).await { - eprintln!("dd-shell: transcript append failed: {e}"); - } - session.meta.write().await.updated_at = unix_ts(); - } - Err(e) => { - eprintln!("dd-shell: {kind} read failed: {e}"); - break; - } - } - } - }); -} - -fn spawn_waiter(store: TranscriptStore, session: Arc) { - tokio::spawn(async move { - let status = { - let mut child = session.child.lock().await; - child.wait().await - }; - mark_session_exited(&store, &session, status.ok().and_then(|s| s.code())).await; - cleanup_session_scratch(&session).await; - }); -} - -async fn mark_session_exited(store: &TranscriptStore, session: &Session, exit_code: Option) { - let mut meta = session.meta.write().await; - if matches!(meta.status, SessionStatus::Exited) { - return; - } - meta.updated_at = unix_ts(); - meta.status = SessionStatus::Exited; - meta.exit_code = exit_code; - if let Err(e) = store.append_meta(&meta).await { - eprintln!("dd-shell: exit meta append failed: {e}"); - } -} - -async fn cleanup_session_scratch(session: &Session) { - if !session.cleanup_scratch_on_exit { - return; - } - let Some(path) = &session.scratch_dir else { - return; - }; - match tokio::fs::remove_dir_all(path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => eprintln!( - "dd-shell: scratch cleanup failed for {}: {e}", - path.display() - ), - } -} - -async fn push_ring(session: &Session, bytes: &[u8]) { - let mut ring = session.ring.lock().await; - for b in bytes { - if ring.len() >= RING_LIMIT { - ring.pop_front(); - } - ring.push_back(*b); - } -} - -impl TranscriptStore { - async fn new(dir: PathBuf) -> Result { - tokio::fs::create_dir_all(&dir).await?; - let key = history_key(&dir).await?; - Ok(Self { dir, key }) - } - - async fn append_meta(&self, meta: &SessionMeta) -> Result<()> { - let bytes = serde_json::to_vec(meta).map_err(|e| Error::Internal(e.to_string()))?; - self.append_bytes(&meta.id, "meta", &bytes).await - } - - async fn append_bytes(&self, id: &str, kind: &str, bytes: &[u8]) -> Result<()> { - let record = TranscriptRecord { - ts: unix_ts(), - kind: kind.to_string(), - data_b64: base64::engine::general_purpose::STANDARD.encode(bytes), - }; - let plain = serde_json::to_vec(&record).map_err(|e| Error::Internal(e.to_string()))?; - let line = self.encrypt_line(&plain)?; - let path = self.path(id); - let mut f = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .await?; - f.write_all(line.as_bytes()).await?; - f.write_all(b"\n").await?; - Ok(()) - } - - async fn replay(&self, id: &str) -> Result> { - let path = self.path(id); - if !Path::new(&path).exists() { - return Err(Error::NotFound); - } - let text = tokio::fs::read_to_string(path).await?; - let mut out = Vec::new(); - for line in text.lines().filter(|l| !l.trim().is_empty()) { - let plain = self.decrypt_line(line)?; - let record: TranscriptRecord = - serde_json::from_slice(&plain).map_err(|e| Error::Internal(e.to_string()))?; - if record.kind == "pty" || record.kind == "stdout" || record.kind == "stderr" { - let bytes = base64::engine::general_purpose::STANDARD - .decode(record.data_b64) - .map_err(|e| Error::Internal(e.to_string()))?; - out.extend_from_slice(&bytes); - } - } - Ok(out) - } - - fn path(&self, id: &str) -> PathBuf { - self.dir.join(format!("{id}.log.enc")) - } - - fn encrypt_line(&self, plain: &[u8]) -> Result { - let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key)); - let mut nonce = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce); - let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce), plain) - .map_err(|e| Error::Internal(format!("encrypt transcript: {e}")))?; - let mut packed = nonce.to_vec(); - packed.extend_from_slice(&ciphertext); - Ok(base64::engine::general_purpose::STANDARD.encode(packed)) - } - - fn decrypt_line(&self, line: &str) -> Result> { - let packed = base64::engine::general_purpose::STANDARD - .decode(line) - .map_err(|e| Error::Internal(e.to_string()))?; - if packed.len() < 13 { - return Err(Error::Internal("truncated transcript record".into())); - } - let (nonce, ciphertext) = packed.split_at(12); - let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key)); - cipher - .decrypt(Nonce::from_slice(nonce), ciphertext) - .map_err(|e| Error::Internal(format!("decrypt transcript: {e}"))) - } -} - -async fn history_key(dir: &Path) -> Result<[u8; 32]> { - if let Ok(raw) = std::env::var("DD_SHELL_HISTORY_KEY") { - let bytes = base64::engine::general_purpose::STANDARD - .decode(raw.trim()) - .or_else(|_| hex::decode(raw.trim())) - .map_err(|_| Error::BadRequest("DD_SHELL_HISTORY_KEY must be base64 or hex".into()))?; - if bytes.len() != 32 { - return Err(Error::BadRequest( - "DD_SHELL_HISTORY_KEY must decode to 32 bytes".into(), - )); - } - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - return Ok(key); - } - - let key_path = dir.join("history.key"); - if let Ok(bytes) = tokio::fs::read(&key_path).await { - if bytes.len() == 32 { - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - return Ok(key); - } - } - - let mut material = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut material); - tokio::fs::write(&key_path, material).await?; - let mut hasher = Sha256::new(); - hasher.update(material); - hasher.update(b"dd-shell-history-v1"); - Ok(hasher.finalize().into()) -} - -fn unix_ts() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_secs() as i64 -} - const XTERM_CSS: &str = include_str!("../assets/xterm/xterm.css"); const XTERM_JS: &str = include_str!("../assets/xterm/xterm.js"); const XTERM_FIT_JS: &str = include_str!("../assets/xterm/addon-fit.js"); From 14cde60d5343d55eea72ac0e3f992585f575a6d6 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:35:02 +0000 Subject: [PATCH 03/13] Document shell fallback removal plan --- README.md | 5 ++- apps/README.md | 32 ++++++++------- docs/sessiond-central-ui-plan.md | 69 ++++++++++++++++++++++++-------- src/sessiond.rs | 4 +- 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7770c04..97aa41f 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,9 @@ without ITA credentials must pass `--insecure-skip-quote-verify` explicitly. The web shell should become another client implementation of the same protocol: it keeps its own paired device identity, asks CP for current routes, and opens -Noise directly to the agent. The existing cookie-auth browser shell remains a -compatibility surface while that client-side Noise implementation lands. +Noise directly to the agent. The existing cookie-auth browser shell is +transitional compatibility only; new shell features should land on the Noise +client protocol. ## STONITH diff --git a/apps/README.md b/apps/README.md index dd3a30a..19795ce 100644 --- a/apps/README.md +++ b/apps/README.md @@ -117,23 +117,25 @@ it does not create a read-write terminal or any input path into the workload. DD separates terminal access by capability: -- **Read-only workload terminals** show workload logs in the dd-shell xterm UI. +- **Read-only workload terminals** show workload logs in the terminal UI. They are for oracle-style services where an operator should be able to inspect output without sending input, resizing a PTY, interrupting, or closing the process. Opening a read-only terminal is observation only, so it leaves the workload's user-facing integrity state clean. -- **Read-write PTY sessions** are created inside `dd-shell`. They are for +- **Read-write PTY sessions** are owned by `dd-sessiond`. They are for confidential shells and ZDR coding agents such as Codex or Claude. These - sessions are reconnectable and write encrypted transcript records under - `DD_SHELL_DIR`. A read-write PTY is controlled as soon as it exists because - the holder can send stdin, resize the terminal, and deliver terminal signals. + sessions are reconnectable and write encrypted transcript records. A + read-write PTY is controlled as soon as it exists because the holder can send + stdin, resize the terminal, and deliver terminal signals. The shell UI treats both as terminal views, but only read-write sessions get WebSocket input, resize, and close controls. Workloads do not opt into read-write access by putting metadata in `workload.json`; the boundary is the -dd-shell API surface. Internally DD may still call this taint tracking, but the -API/UI should speak in integrity terms: clean for observed-only logs, -controlled for interactive PTYs or other human control paths. +session protocol exposed by `dd-agent` over Noise. The current browser shell +HTTP/WebSocket APIs are compatibility only while web/PWA moves to direct Noise. +Internally DD may still call this taint tracking, but the API/UI should speak in +integrity terms: clean for observed-only logs, controlled for interactive PTYs +or other human control paths. The renderer uses vendored xterm assets and recognizes WezTerm-compatible notification escapes after the user grants browser notification permission: @@ -192,12 +194,14 @@ Additional examples: - `apps/oracle-readonly`: standalone oracle example with the same scraper and vanity-address metadata; copy this shape into real oracle app repos. - `apps/confidential-shell`: legacy standalone shell workload for deployments - that still run the browser shell and PTY supervisor in one process. -- `apps/codex-podman-shell`: alternative read-write shell workload. It exposes - the normal `-shell` label and carries an older self-contained Codex recipe - path. New deployments should prefer `dd-sessiond` + `dd-shell`. - -CP stays slim: `cloudflared` + `dd-management` + `dd-sessiond` + `dd-shell`. + that still run the browser shell and PTY supervisor in one process. Scheduled + for removal once all clients use `dd-sessiond` over Noise. +- `apps/codex-podman-shell`: legacy read-write shell workload. It exposes the + normal `-shell` label and carries an older self-contained Codex recipe path. + Scheduled for removal; new deployments should use `dd-sessiond`. + +CP stays slim: `cloudflared` + `dd-management` + static/web client assets as +needed. It must not carry shell, log, transcript, or PTY bytes. Preview agent VMs run a small read-only oracle plus agent + podman for CI to prove registration, scraping, vanity ingress, and dashboards end-to-end. Prod agent VMs use the diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index 1b9f80a..e68f7ac 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -70,32 +70,34 @@ Acceptance: - Existing session metadata and transcripts are visible through the session API. - Restarting only the web/UI layer does not kill a running Codex session. -## Phase 2: Stable Session API +## Phase 2: Stable Client Protocol -Expose session functionality through `dd-agent`, backed by local -`dd-sessiond`. +Expose session functionality through `dd-agent` Noise, backed by local +`dd-sessiond`. The stable remote surface is the paired-device protocol, not +cookie-auth HTTP session routes. -Initial API: +Initial protocol: ```text -GET /api/sessions -POST /api/sessions -GET /api/sessions/:id -WS /api/sessions/:id/attach -POST /api/sessions/:id/input -POST /api/sessions/:id/resize -POST /api/sessions/:id/close -GET /api/sessions/:id/transcript -WS /api/session-events +shell.list_recipes +shell.list_sessions +shell.create_session +shell.replay_session +shell.resize_session +shell.close_session +shell.attach_session +exec ``` Implementation notes: - `dd-sessiond` listens only on a Unix socket on the VM. -- `dd-agent` is the only remote gateway. +- `dd-agent` is the only remote session gateway. - Multiple clients may attach to the same session. - Transcript and events are canonical session state, not UI state. - Mobile clients should not resize the canonical PTY by default. +- The existing `/api/sessions*` and `/ws/sessions*` browser-shell routes are + transitional compatibility only. Do not add new client features there. ## Phase 3: Client-Side Fleet UI @@ -148,8 +150,12 @@ Implemented first slice: `shell.close_session` JSON requests over Noise. - `shell.attach_session` returns one JSON ack, then switches the same Noise session into encrypted raw PTY byte streaming to local `dd-sessiond`. +- Native `exec`, `replay`, `resize`, and `close` commands exercise the same + direct Noise path as `shell`. - CP Noise endpoints reject shell methods because they have no local sessiond adapter; agent Noise endpoints wire the adapter in. +- The native CLI appraises the agent quote with Intel Trust Authority by + default and requires an explicit insecure flag for local preview/dev. Design rule: @@ -161,10 +167,41 @@ dd-sessiond stays local-only dd-agent is the policy/encryption gateway ``` +## Removal Plan + +Now that the durable session owner is `dd-sessiond` and native clients can use +direct Noise, remove the old shell stack in this order: + +1. Freeze cookie-auth browser shell APIs. Treat `src/shell.rs` routes + `/api/sessions*` and `/ws/sessions*` as compatibility only until the web/PWA + client speaks Noise directly. +2. Remove old env compatibility names. `DD_SESSIOND_HISTORY_KEY` is the only + transcript-key override; do not continue accepting `DD_SHELL_HISTORY_KEY`. +3. Move web/PWA to direct Noise. Store a paired device key in browser storage, + use CP only for enrollment and route discovery, then connect to the selected + agent `/noise/ws` for session RPCs and PTY bytes. +4. Delete server-side browser shell proxying. Remove `src/shell.rs` session + proxy routes and WebSocket attach path once the web/PWA client uses direct + Noise. Keep only static asset serving if needed. +5. Delete agent HTTP session proxying. Remove `/api/sessions*` from `dd-agent` + once native and web clients both use Noise for session control. +6. Retire legacy combined shell workloads. Remove `apps/confidential-shell` and + `apps/codex-podman-shell` after deploy templates and docs no longer point at + `DD_MODE=shell` as a PTY owner. +7. Rename remaining storage paths only with an explicit data migration. The + current `dd-shell` path names are confusing but may contain persistent + transcripts; do not silently strand them. + +Keep these pieces: + +- `dd-sessiond` and its local-only API. It is the session owner, not a fallback. +- `shell_unavailable` on CP Noise endpoints. It is an explicit rejection for a + process that intentionally has no local sessiond. +- CP route discovery and enrollment. CP stays in the trust/control path, not + the shell data path. + ## Risks And Open Questions -- Whether to keep a minimal cookie-auth shell UI only as temporary emergency - compatibility. - How to model recipes so CP UI and `dd-sessiond` agree on available launchers. - How to represent "input requested" events for Codex/Claude without making terminal parsing authoritative. diff --git a/src/sessiond.rs b/src/sessiond.rs index f92e7b2..51bbf9f 100644 --- a/src/sessiond.rs +++ b/src/sessiond.rs @@ -996,9 +996,7 @@ impl TranscriptStore { } async fn history_key(dir: &Path) -> Result<[u8; 32]> { - if let Ok(raw) = - std::env::var("DD_SESSIOND_HISTORY_KEY").or_else(|_| std::env::var("DD_SHELL_HISTORY_KEY")) - { + if let Ok(raw) = std::env::var("DD_SESSIOND_HISTORY_KEY") { let bytes = base64::engine::general_purpose::STANDARD .decode(raw.trim()) .or_else(|_| hex::decode(raw.trim())) From 3a022050f9b6a7d0a3ad4b7f66a875f72f0d7d71 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:41:53 +0000 Subject: [PATCH 04/13] Plan durable device enrollment before shell removal --- docs/sessiond-central-ui-plan.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index e68f7ac..139f07f 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -141,6 +141,8 @@ Then strengthen: - Clients verify the agent quote and pin the attested Noise public key. - Browser/PWA uses the same direct agent Noise path once the browser crypto or WASM client is in place. +- CP device enrollment survives CP redeploys. A paired native/web/mobile client + must not need to re-pair just because preview or production CP was relaunched. Implemented first slice: @@ -177,18 +179,21 @@ direct Noise, remove the old shell stack in this order: client speaks Noise directly. 2. Remove old env compatibility names. `DD_SESSIOND_HISTORY_KEY` is the only transcript-key override; do not continue accepting `DD_SHELL_HISTORY_KEY`. -3. Move web/PWA to direct Noise. Store a paired device key in browser storage, +3. Fix device registry durability. The CP trusted-device list must survive a + CP redeploy or hydrate from the predecessor before clients depend on it as + their only route to shell sessions. +4. Move web/PWA to direct Noise. Store a paired device key in browser storage, use CP only for enrollment and route discovery, then connect to the selected agent `/noise/ws` for session RPCs and PTY bytes. -4. Delete server-side browser shell proxying. Remove `src/shell.rs` session +5. Delete server-side browser shell proxying. Remove `src/shell.rs` session proxy routes and WebSocket attach path once the web/PWA client uses direct Noise. Keep only static asset serving if needed. -5. Delete agent HTTP session proxying. Remove `/api/sessions*` from `dd-agent` +6. Delete agent HTTP session proxying. Remove `/api/sessions*` from `dd-agent` once native and web clients both use Noise for session control. -6. Retire legacy combined shell workloads. Remove `apps/confidential-shell` and +7. Retire legacy combined shell workloads. Remove `apps/confidential-shell` and `apps/codex-podman-shell` after deploy templates and docs no longer point at `DD_MODE=shell` as a PTY owner. -7. Rename remaining storage paths only with an explicit data migration. The +8. Rename remaining storage paths only with an explicit data migration. The current `dd-shell` path names are confusing but may contain persistent transcripts; do not silently strand them. @@ -210,3 +215,5 @@ Keep these pieces: - How much of the existing transcript encryption format should move unchanged into `dd-sessiond`. - Browser Noise implementation choice: pure JS library versus small WASM client. +- CP device registry storage location for SSH/preview CPs, since `/tmp` state + can disappear across relaunches if predecessor hydration does not run. From 84ae36132eb3da25c4d4e61a00afe92e4f84e4cc Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:43:10 +0000 Subject: [PATCH 05/13] Clarify CP does not own shell state --- docs/sessiond-central-ui-plan.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index 139f07f..b4b9099 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -131,8 +131,8 @@ Status: Start simple: -- CP enrolls paired device public keys and exposes current routes. -- Agents poll CP for the trusted device set and route/policy freshness. +- CP brokers enrollment and exposes current routes. +- Agents hold the trusted device set they enforce for direct Noise sessions. - Native CLI/desktop/mobile clients use direct `/noise/ws` channels for session RPCs. @@ -141,8 +141,9 @@ Then strengthen: - Clients verify the agent quote and pin the attested Noise public key. - Browser/PWA uses the same direct agent Noise path once the browser crypto or WASM client is in place. -- CP device enrollment survives CP redeploys. A paired native/web/mobile client - must not need to re-pair just because preview or production CP was relaunched. +- Pairing survives CP redeploys without putting shell/session state in CP. A + paired native/web/mobile client must not need to re-pair just because preview + or production CP was relaunched. Implemented first slice: @@ -163,7 +164,7 @@ Design rule: ```text remote clients never trust naked tunnel DNS -remote clients trust CP-enrolled device identity plus attested agent keys +remote clients trust paired device identity plus attested agent keys CP is route/key authority only, never a session data plane dd-sessiond stays local-only dd-agent is the policy/encryption gateway @@ -179,9 +180,9 @@ direct Noise, remove the old shell stack in this order: client speaks Noise directly. 2. Remove old env compatibility names. `DD_SESSIOND_HISTORY_KEY` is the only transcript-key override; do not continue accepting `DD_SHELL_HISTORY_KEY`. -3. Fix device registry durability. The CP trusted-device list must survive a - CP redeploy or hydrate from the predecessor before clients depend on it as - their only route to shell sessions. +3. Fix pairing durability without making CP a shell/session state owner. If CP + participates in enrollment, it should broker or cache trust material rather + than become the only durable source needed to reach shell sessions. 4. Move web/PWA to direct Noise. Store a paired device key in browser storage, use CP only for enrollment and route discovery, then connect to the selected agent `/noise/ws` for session RPCs and PTY bytes. @@ -203,7 +204,7 @@ Keep these pieces: - `shell_unavailable` on CP Noise endpoints. It is an explicit rejection for a process that intentionally has no local sessiond. - CP route discovery and enrollment. CP stays in the trust/control path, not - the shell data path. + the shell data path or shell state path. ## Risks And Open Questions @@ -215,5 +216,5 @@ Keep these pieces: - How much of the existing transcript encryption format should move unchanged into `dd-sessiond`. - Browser Noise implementation choice: pure JS library versus small WASM client. -- CP device registry storage location for SSH/preview CPs, since `/tmp` state - can disappear across relaunches if predecessor hydration does not run. +- Where durable paired-device trust should live if CP only brokers enrollment + and route discovery. From 6f5a82fa23b6e0236949a8a009e90fd26e94b318 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:51:42 +0000 Subject: [PATCH 06/13] Move paired device trust to agents --- README.md | 3 +- apps/dd-agent/workload.json.tmpl | 1 + docs/sessiond-central-ui-plan.md | 17 +-- src/agent.rs | 233 ++++++++++++++++++++---------- src/cf.rs | 3 +- src/config.rs | 22 +-- src/cp.rs | 235 +++++-------------------------- src/devices.rs | 13 +- src/noise_gateway/mod.rs | 3 +- 9 files changed, 226 insertions(+), 304 deletions(-) diff --git a/README.md b/README.md index 97aa41f..2988c0c 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ Trust Authority, checks that the quote binds `noise.pubkey_hex` into TDX `shell.replay_session`, `shell.resize_session`, `shell.close_session`, and the streaming `shell.attach_session` method. Session control and PTY bytes flow inside the Noise transport to the agent and then to local `dd-sessiond`; the CP -is used for enrollment and route discovery, not for shell/log/session bytes. +is used for enrollment brokering and route discovery, not for shell/log/session +bytes or paired-device trust storage. The bundled native CLI exercises that path directly: diff --git a/apps/dd-agent/workload.json.tmpl b/apps/dd-agent/workload.json.tmpl index 359045e..30812a7 100644 --- a/apps/dd-agent/workload.json.tmpl +++ b/apps/dd-agent/workload.json.tmpl @@ -28,6 +28,7 @@ "DD_CONFIDENTIAL=${DD_CONFIDENTIAL}", "DD_PORT=8080", "DD_EXTRA_INGRESS=${DD_EXTRA_INGRESS}", + "DD_AGENT_DEVICES_PATH=/var/lib/easyenclave/data/dd-agent/devices.json", "DD_ORACLES_B64=${DD_ORACLES_B64}" ] } diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index b4b9099..ce80db3 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -10,7 +10,7 @@ dogfood VM or killing active Codex/Claude sessions. ```text native/web/mobile client - -> CP route discovery + device enrollment + -> CP route discovery + enrollment broker -> selected agent /noise/ws -> dd-sessiond Unix socket on that VM -> PTY + child process group @@ -27,7 +27,7 @@ Agent VM responsibilities: Control-plane responsibilities: -- Own device enrollment, revocation, policy, and route discovery. +- Broker device enrollment and own route discovery. - Optionally serve static web/PWA assets. - Never carry shell, log, transcript, or PTY bytes. @@ -131,7 +131,8 @@ Status: Start simple: -- CP brokers enrollment and exposes current routes. +- CP brokers enrollment by redirecting to the selected agent and exposes + current routes. - Agents hold the trusted device set they enforce for direct Noise sessions. - Native CLI/desktop/mobile clients use direct `/noise/ws` channels for session RPCs. @@ -180,9 +181,9 @@ direct Noise, remove the old shell stack in this order: client speaks Noise directly. 2. Remove old env compatibility names. `DD_SESSIOND_HISTORY_KEY` is the only transcript-key override; do not continue accepting `DD_SHELL_HISTORY_KEY`. -3. Fix pairing durability without making CP a shell/session state owner. If CP - participates in enrollment, it should broker or cache trust material rather - than become the only durable source needed to reach shell sessions. +3. Fix pairing durability without making CP a shell/session state owner. CP can + broker enrollment, but durable paired-device trust must live with the + enforcement point or an explicitly chosen non-CP store. 4. Move web/PWA to direct Noise. Store a paired device key in browser storage, use CP only for enrollment and route discovery, then connect to the selected agent `/noise/ws` for session RPCs and PTY bytes. @@ -203,8 +204,8 @@ Keep these pieces: - `dd-sessiond` and its local-only API. It is the session owner, not a fallback. - `shell_unavailable` on CP Noise endpoints. It is an explicit rejection for a process that intentionally has no local sessiond. -- CP route discovery and enrollment. CP stays in the trust/control path, not - the shell data path or shell state path. +- CP route discovery and enrollment brokering. CP stays in the trust/control + path, not the shell data path or shell state path. ## Risks And Open Questions diff --git a/src/agent.rs b/src/agent.rs index 8b9724d..9f92f5f 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -7,9 +7,9 @@ //! Auth after registration: //! - Browser routes (`/`, `/workload/*`) require a DD-signed //! GitHub App session cookie. Cloudflare only routes traffic. -//! - Terminal access is provided by the `dd-shell` workload on the -//! `shell` label. It exposes read-only workload logs and read-write -//! PTY sessions as separate capabilities. +//! - Terminal access is provided by direct paired-device Noise sessions +//! to this agent, with browser shell HTTP routes kept as transitional +//! compatibility. //! - `/deploy` and `/exec` are gated in-code by a GitHub Actions //! OIDC token — any CI workflow whose //! principal matches `DD_OWNER`/`DD_OWNER_ID`/`DD_OWNER_KIND` @@ -25,7 +25,7 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; use base64::Engine as _; use futures_util::{SinkExt, StreamExt}; @@ -53,10 +53,6 @@ use crate::units::{self, AgentMode, ManagedUnit, UnitKind}; /// token to the CP's collector. const ITA_REFRESH: Duration = Duration::from_secs(180); -/// Poll interval for syncing the device trust list from the CP. -/// Tuned so a revoke propagates within ~30s. -const DEVICES_POLL: Duration = Duration::from_secs(30); - const EE_READY_TIMEOUT: Duration = Duration::from_secs(90); #[derive(Clone)] @@ -110,6 +106,9 @@ struct St { /// Local raw attach socket used to proxy PTY bytes over WebSocket. sessiond_attach_addr: String, http: reqwest::Client, + /// Agent-local paired-device store. This is the trust source enforced by + /// the local Noise gateway. + devices: Arc, } pub async fn run() -> Result<()> { @@ -162,24 +161,9 @@ pub async fn run() -> Result<()> { // CLIs can attach directly to the agent's EE instance without // going through the CP. let trust = noise_gateway::new_trust_handle(); - - // Background poll for the device trust list. Mutates `trust` - // in place so the local Noise responder picks up revocations - // within ~DEVICES_POLL. - { - let cp_url = cfg.cp_url.clone(); - let token = ita_token.clone(); - let trust = trust.clone(); - tokio::spawn(async move { - let http = crate::system_http_client(); - loop { - if let Err(e) = sync_trusted_devices(&http, &cp_url, &token, &trust).await { - eprintln!("agent: device sync failed: {e}"); - } - tokio::time::sleep(DEVICES_POLL).await; - } - }); - } + let devices = crate::devices::Store::load(cfg.devices_path.clone(), trust.clone()) + .await + .map_err(|e| Error::Internal(format!("agent devices store load: {e}")))?; // Attestation keypair + upstream EE client for the Noise gateway. let noise_key_path: std::path::PathBuf = std::env::var("DD_NOISE_KEY_PATH") @@ -238,6 +222,7 @@ pub async fn run() -> Result<()> { sessiond_http_url, sessiond_attach_addr, http: crate::system_http_client(), + devices, }; let api_state = state.clone(); let api_ng_state = ng_state.clone(); @@ -259,6 +244,9 @@ pub async fn run() -> Result<()> { .route("/api/sessions/{id}/resize", post(resize_session)) .route("/api/sessions/{id}/close", post(close_session)) .route("/api/sessions/{id}/attach", get(attach_session)) + .route("/api/v1/devices", post(create_device)) + .route("/api/v1/devices/{pubkey}", delete(revoke_device)) + .route("/admin/enroll", get(enroll_page)) .route("/workload/{id}", get(workload_page)) .route("/logs/{app}", get(logs)); if !cfg.confidential { @@ -374,51 +362,6 @@ async fn log_http( res } -/// Pull the CP's device registry (`{"pubkeys": ["", ...]}`) and -/// atomically replace the local `TrustHandle`. The local Noise -/// responder reads this set directly; revocations propagate within -/// one `DEVICES_POLL` tick. -async fn sync_trusted_devices( - http: &reqwest::Client, - cp_url: &str, - ita_token: &Arc>, - trust: &noise_gateway::TrustHandle, -) -> Result<()> { - // `/api/v1/devices/trusted` is reachable over the public tunnel. - // Auth is in-code: loopback / GH-OIDC / ITA, - // same three-way policy as `/api/agents`. - let url = format!("{}/api/v1/devices/trusted", cp_url.trim_end_matches('/')); - let token = ita_token.read().await.clone(); - let resp = http - .get(&url) - .bearer_auth(token) - .send() - .await - .map_err(|e| Error::Upstream(format!("devices GET {url}: {e}")))?; - if !resp.status().is_success() { - return Err(Error::Upstream(format!( - "devices GET {url} → {}", - resp.status() - ))); - } - let body: serde_json::Value = resp.json().await?; - let mut fresh: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new(); - if let Some(arr) = body["pubkeys"].as_array() { - for v in arr { - let Some(s) = v.as_str() else { continue }; - let Ok(bytes) = hex::decode(s) else { continue }; - if bytes.len() != 32 { - continue; - } - let mut k = [0u8; 32]; - k.copy_from_slice(&bytes); - fresh.insert(k); - } - } - *trust.write().await = fresh; - Ok(()) -} - #[derive(Debug, serde::Deserialize)] struct Bootstrap { tunnel_token: String, @@ -997,6 +940,154 @@ async fn attach_session( })) } +#[derive(Debug, Deserialize)] +struct CreateDeviceReq { + pubkey: String, + label: String, +} + +/// POST /api/v1/devices — enroll a device pubkey on this agent. +/// Idempotent on pubkey: re-posting with a new label replaces the record. +async fn create_device( + State(s): State, + headers: HeaderMap, + uri: Uri, + Json(req): Json, +) -> Result<(axum::http::StatusCode, Json)> { + ensure_browser_auth(&s, &headers, &uri)?; + let pubkey = req.pubkey.to_lowercase(); + crate::devices::validate_hex_pubkey(&pubkey).map_err(|e| Error::BadRequest(e.to_string()))?; + let label = req.label.trim().to_string(); + if label.is_empty() || label.len() > 128 { + return Err(Error::BadRequest("label must be 1..=128 chars".into())); + } + let device = crate::devices::Device { + pubkey, + label, + created_at_ms: chrono::Utc::now().timestamp_millis(), + revoked_at_ms: None, + }; + s.devices + .upsert(device.clone()) + .await + .map_err(|e| Error::Internal(format!("devices upsert: {e}")))?; + Ok((axum::http::StatusCode::CREATED, Json(device))) +} + +/// DELETE /api/v1/devices/{pubkey} — revoke a paired device on this agent. +async fn revoke_device( + State(s): State, + headers: HeaderMap, + uri: Uri, + Path(pubkey): Path, +) -> Result> { + ensure_browser_auth(&s, &headers, &uri)?; + let pubkey = pubkey.to_lowercase(); + let now = chrono::Utc::now().timestamp_millis(); + let ok = s + .devices + .revoke(&pubkey, now) + .await + .map_err(|e| Error::Internal(format!("devices revoke: {e}")))?; + if !ok { + return Err(Error::NotFound); + } + Ok(Json(serde_json::json!({ + "revoked": pubkey, + "at_ms": now, + }))) +} + +/// GET /admin/enroll?pubkey=...&label=... — authenticated confirmation +/// page. The mutation lands on this agent, so CP does not own paired-device +/// trust. +async fn enroll_page( + State(s): State, + headers: HeaderMap, + uri: Uri, + Query(q): Query>, +) -> Response { + if let Some(resp) = require_browser_auth(&s, &headers, &uri) { + return resp; + } + let pubkey = q.get("pubkey").cloned().unwrap_or_default(); + let label = q.get("label").cloned().unwrap_or_default(); + + if let Err(e) = crate::devices::validate_hex_pubkey(&pubkey) { + return Html(shell( + "Enroll device", + "", + &format!( + r#"

Invalid pubkey

{}

"#, + html::escape(&e.to_string()) + ), + )) + .into_response(); + } + if label.trim().is_empty() || label.len() > 128 { + return Html(shell( + "Enroll device", + "", + r#"

Invalid label

label must be 1..=128 chars

"#, + )) + .into_response(); + } + + let short = &pubkey[..16]; + let body = format!( + r#"
+

Enroll this device?

+
Label{label}
+
Pubkey{short}...
+

+ Confirming adds this X25519 public key to this agent's trust list. A + client holding the matching private key can open Noise_IK sessions to this + enclave. Revoke with DELETE /api/v1/devices/<pubkey>. +

+

+
+ + Cancel +
+
+"#, + label = html::escape(&label), + short = html::escape(short), + pubkey_js = serde_json::to_string(&pubkey).unwrap_or_else(|_| "\"\"".into()), + label_js = serde_json::to_string(&label).unwrap_or_else(|_| "\"\"".into()), + ); + + Html(shell("Enroll device", "", &body)).into_response() +} + #[derive(Debug, Deserialize)] struct AttachQuery { tail: Option, diff --git a/src/cf.rs b/src/cf.rs index ba3d994..a827a59 100644 --- a/src/cf.rs +++ b/src/cf.rs @@ -506,7 +506,6 @@ pub async fn delete_cp_access_apps( [ "/health", "/api/agents", - "/api/v1/devices/trusted", "/api/v1/admin/export", "/noise/ws", "/register", @@ -533,7 +532,7 @@ pub async fn delete_cp_access_apps( /// - `{agent}.{domain}/owner` — fleet-GH-OIDC-gated in code /// - `{agent}.{domain}/logs/*` — GH-OIDC-gated in code /// - `{agent}.{domain}/noise/ws` — Noise_IK-gated in code -/// against the CP-trusted paired device pubkey set +/// against the agent-local paired device pubkey set /// - `{label}.{agent}.{domain}` — workload URL, DD/agent-owned pub async fn delete_agent_access_apps( http: &Client, diff --git a/src/config.rs b/src/config.rs index 5d49d79..cdb0e4e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,9 +168,6 @@ pub struct Cp { pub scraper_shard_index: u64, pub scraper_shard_total: u64, pub ita: Ita, - /// Source-of-truth file for the device registry (JSON). Survives - /// CP restart; mutations fsync through to disk. - pub devices_path: std::path::PathBuf, /// Where the Noise gateway persists its X25519 static private key /// (tmpfs). Fresh per-boot when missing. pub noise_key_path: std::path::PathBuf, @@ -207,17 +204,6 @@ impl Cp { "DD_SCRAPER_SHARD_INDEX ({scraper_shard_index}) must be less than DD_SCRAPER_SHARD_TOTAL ({scraper_shard_total})" ))); } - // `/tmp/` (tmpfs) is the only universally-writable path in the - // EE workload sandbox — the root FS is RO, and `/var/lib/` + - // `/data/` are both unavailable on the CP VM (no mount-data - // in the CP boot set; root FS read-only for workloads). - // Ephemeral across CP restarts is OK: the zero-downtime - // boot hydrates devices from the predecessor CP via - // `/api/v1/admin/export` before flipping DNS, so the on-disk - // copy is a cache, not source of truth. - let devices_path = std::env::var("DD_CP_DEVICES_PATH") - .unwrap_or_else(|_| "/tmp/devopsdefender/devices.json".into()) - .into(); let noise_key_path = std::env::var("DD_NOISE_KEY_PATH") .unwrap_or_else(|_| "/run/devopsdefender/noise.key".into()) .into(); @@ -232,7 +218,6 @@ impl Cp { scraper_shard_index, scraper_shard_total, ita, - devices_path, noise_key_path, }) } @@ -285,6 +270,9 @@ pub struct Agent { /// third parties that the code is sealed. Backward-compatible: /// unset = default (mutation endpoints enabled). pub confidential: bool, + /// Source-of-truth file for paired-device pubkeys enforced by this + /// agent's Noise gateway. + pub devices_path: std::path::PathBuf, /// Read-only oracle endpoints baked into the boot workload set. /// `apps/_infra/local-agents.sh` extracts DD-level `oracle` /// metadata from workload JSON and injects it here as base64 JSON @@ -310,6 +298,9 @@ impl Agent { .unwrap_or_else(|_| "/var/lib/easyenclave/agent.sock".into()); let extra_ingress = parse_extra_ingress()?; let confidential = parse_truthy("DD_CONFIDENTIAL"); + let devices_path = std::env::var("DD_AGENT_DEVICES_PATH") + .unwrap_or_else(|_| "/var/lib/easyenclave/data/dd-agent/devices.json".into()) + .into(); let oracles = parse_oracles()?; let ita = Ita::from_env(&common.env_label)?; Ok(Self { @@ -320,6 +311,7 @@ impl Agent { auth, extra_ingress, confidential, + devices_path, oracles, }) } diff --git a/src/cp.rs b/src/cp.rs index f8213e8..a9d70f4 100644 --- a/src/cp.rs +++ b/src/cp.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri}; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; @@ -54,9 +54,6 @@ struct St { /// GH OIDC verifier for `/api/agents` callers (CI, humans). Same /// audience as dd-agent, shared owner claim. gh: Arc, - /// Paired device pubkeys. Mutations persist to disk and emit a - /// runtime view for the local ee-proxy workload. - devices: Arc, /// TDX-quote + Noise-static-pubkey bundle. Surfaced by `/health` /// as `{ noise: { quote_b64, pubkey_hex } }` so a bastion-app /// bootstraps in one fetch (the former standalone `/attest` @@ -139,9 +136,6 @@ pub async fn run() -> Result<()> { // hostname at all. let store: Store = Arc::new(Mutex::new(HashMap::new())); let trust = noise_gateway::new_trust_handle(); - let devices = crate::devices::Store::load(cfg.devices_path.clone(), trust.clone()) - .await - .map_err(|e| Error::Internal(format!("devices store load: {e}")))?; let predecessor_prefix = cf::cp_prefix(&cfg.common.env_label); let has_predecessor = match cf::list(&http, &cfg.cf).await { Ok(tunnels) => tunnels.iter().any(|t| { @@ -159,7 +153,7 @@ pub async fn run() -> Result<()> { } }; if has_predecessor { - hydrate_from_peer(&http, &cfg.hostname, &initial_token, &devices, &store).await; + hydrate_from_peer(&http, &cfg.hostname, &initial_token, &store).await; } else { eprintln!("cp: no predecessor {predecessor_prefix}* tunnel — skipping hydrate (fresh env)"); } @@ -336,8 +330,8 @@ pub async fn run() -> Result<()> { let gh = crate::gh_oidc::Verifier::new(cfg.common.owner.clone(), "dd-agent".into()); - // Noise gateway state. `devices` already loaded in Stage 2 + any - // inherited records merged in; `trust` is already populated. + // CP serves its attested Noise key for management methods, but it does not + // own paired-device trust for shell sessions. let attestor = Arc::new( noise_gateway::attest::Attestor::load_or_mint(&cfg.noise_key_path) .await @@ -365,7 +359,6 @@ pub async fn run() -> Result<()> { verifier, cp_ita_token, gh, - devices, attest: attestor, }; @@ -379,12 +372,6 @@ pub async fn run() -> Result<()> { .route("/agent/{id}", get(agent_detail)) .route("/agent/{id}/logs/{app}", get(agent_logs)) .route("/api/agents", get(api_agents)) - .route("/api/v1/devices", post(create_device)) - .route("/api/v1/devices/trusted", get(list_trusted_devices)) - .route( - "/api/v1/devices/{pubkey}", - axum::routing::delete(revoke_device), - ) .route("/api/v1/admin/export", get(export_state)) .route("/admin/enroll", get(enroll_page)) .with_state(state) @@ -436,8 +423,8 @@ fn spawn_cloudflared(token: String) { }); } -/// Try to pull devices + agent snapshot from a predecessor CP still -/// serving at `hostname`. The CNAME hasn't flipped yet when this runs, +/// Try to pull an agent snapshot from a predecessor CP still serving +/// at `hostname`. The CNAME hasn't flipped yet when this runs, /// so any existing DNS record still points at the old CP's tunnel. /// Failures (first boot, DNS miss, old code, timeout) are logged and /// swallowed — deploy still proceeds as if fresh. @@ -445,7 +432,6 @@ async fn hydrate_from_peer( http: &reqwest::Client, hostname: &str, ita_token: &str, - devices: &crate::devices::Store, agents: &Store, ) { let url = format!("https://{hostname}/api/v1/admin/export"); @@ -475,21 +461,6 @@ async fn hydrate_from_peer( } }; - let mut imported_devices = 0usize; - if let Some(arr) = body.get("devices").cloned() { - match serde_json::from_value::>(arr) { - Ok(devs) => { - let n = devs.len(); - if let Err(e) = devices.import_merge(devs).await { - eprintln!("cp: hydrate devices.import_merge: {e}"); - } else { - imported_devices = n; - } - } - Err(e) => eprintln!("cp: hydrate devices shape mismatch: {e}"), - } - } - let mut imported_agents = 0usize; if let Some(arr) = body.get("agents").cloned() { match serde_json::from_value::>(arr) { @@ -504,9 +475,7 @@ async fn hydrate_from_peer( } } - eprintln!( - "cp: hydrated from {hostname} — {imported_devices} device(s), {imported_agents} agent(s)" - ); + eprintln!("cp: hydrated from {hostname} — {imported_agents} agent(s)"); } // ── Routes ────────────────────────────────────────────────────────────── @@ -992,20 +961,17 @@ async fn fleet(State(s): State, headers: HeaderMap, uri: Uri) -> Response { .into_response() } -// ── Devices API ───────────────────────────────────────────────────────── +// ── Enrollment broker ─────────────────────────────────────────────────── // -// Paired client-device X25519 pubkeys that the local Noise gateway -// accepts during the handshake. POST + DELETE are behind DD browser -// auth (admin enrollment); the machine-readable `/trusted` view is -// gated in-code for cross-VM agent polls. +// CP keeps enrollment stateless: it authenticates the browser and redirects +// to a read-write agent. The agent stores and enforces paired-device trust. /// GET /api/v1/admin/export — full state snapshot for a successor CP -/// to hydrate from during a zero-downtime deploy. Returns the -/// device registry (full records, including revoked) and the live +/// to hydrate from during a zero-downtime deploy. Returns the live /// agents HashMap. Gated in-code by a valid owner-scoped ITA Bearer -/// (any attested enclave in the -/// fleet can authenticate). The new CP calls this against the old -/// CP's still-pointed DNS before flipping CNAMEs. +/// (any attested enclave in the fleet can authenticate). The new CP +/// calls this against the old CP's still-pointed DNS before flipping +/// CNAMEs. async fn export_state( State(s): State, headers: axum::http::HeaderMap, @@ -1019,110 +985,16 @@ async fn export_state( // MRTD list once we stop rotating measurements every dev push. let _ = s.verifier.verify(bearer).await?; - let devices = s.devices.export_full().await; let agents: Vec = s.store.lock().await.values().cloned().collect(); Ok(Json(serde_json::json!({ - "devices": devices, "agents": agents, }))) } -/// GET /api/v1/devices/trusted — minimal, machine-readable view: -/// `{ "pubkeys": ["", ...] }` with only currently-trusted keys. -/// Reachable by cross-VM dd-agent callers over the public tunnel; -/// gated in-code by the same three-way policy as -/// `/api/agents`. This is the agent's poll target for mirroring the -/// trust list into its in-memory `TrustHandle`. -async fn list_trusted_devices( - State(s): State, - axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, - headers: axum::http::HeaderMap, -) -> Result> { - if !agents_auth_ok(&s, peer, &headers).await { - return Err(Error::Unauthorized); - } - let devices = s.devices.list().await; - let pubkeys: Vec = devices - .into_iter() - .filter(|d| d.revoked_at_ms.is_none()) - .map(|d| d.pubkey) - .collect(); - Ok(Json(serde_json::json!({ "pubkeys": pubkeys }))) -} - -#[derive(Debug, Deserialize)] -struct CreateDeviceReq { - pubkey: String, - label: String, -} - -/// POST /api/v1/devices — enroll a device pubkey. Idempotent on -/// pubkey: re-posting with a new label replaces the record in place. -async fn create_device( - State(s): State, - headers: HeaderMap, - uri: Uri, - Json(req): Json, -) -> Result<(axum::http::StatusCode, Json)> { - if require_browser_auth(&s, &headers, &uri).is_some() { - return Err(Error::Unauthorized); - } - let pubkey = req.pubkey.to_lowercase(); - crate::devices::validate_hex_pubkey(&pubkey).map_err(|e| Error::BadRequest(e.to_string()))?; - let label = req.label.trim().to_string(); - if label.is_empty() || label.len() > 128 { - return Err(Error::BadRequest("label must be 1..=128 chars".into())); - } - let device = crate::devices::Device { - pubkey, - label, - created_at_ms: chrono::Utc::now().timestamp_millis(), - revoked_at_ms: None, - }; - s.devices - .upsert(device.clone()) - .await - .map_err(|e| Error::Internal(format!("devices upsert: {e}")))?; - Ok((axum::http::StatusCode::CREATED, Json(device))) -} - -/// DELETE /api/v1/devices/{pubkey} — revoke. Returns 404 if the -/// pubkey isn't known or was already revoked. -async fn revoke_device( - State(s): State, - headers: HeaderMap, - uri: Uri, - Path(pubkey): Path, -) -> Result> { - if require_browser_auth(&s, &headers, &uri).is_some() { - return Err(Error::Unauthorized); - } - let pubkey = pubkey.to_lowercase(); - let now = chrono::Utc::now().timestamp_millis(); - let ok = s - .devices - .revoke(&pubkey, now) - .await - .map_err(|e| Error::Internal(format!("devices revoke: {e}")))?; - if !ok { - return Err(Error::NotFound); - } - Ok(Json(serde_json::json!({ - "revoked": pubkey, - "at_ms": now, - }))) -} - /// GET /admin/enroll?pubkey=…&label=… — human-facing confirmation -/// page that a `bastion-app` (CLI or desktop) bounces the operator -/// to. Behind DD browser auth: by the time this handler renders, the -/// browser has a valid DD session cookie. The rendered page POSTs to `/api/v1/devices` with the -/// same cookie via `credentials: "same-origin"`, completing the -/// enrollment that headless clients can't do themselves. -/// -/// Intent-over-GET: we deliberately don't enroll on page load — -/// the user clicks Confirm so a copy-pasted link can't silently -/// add a pubkey. +/// broker. CP does not store paired-device trust; it redirects the +/// authenticated browser to a healthy read-write agent, where the +/// agent-local enrollment page performs the mutation. async fn enroll_page( State(s): State, headers: HeaderMap, @@ -1155,61 +1027,26 @@ async fn enroll_page( .into_response(); } - let short = &pubkey[..16]; - let body = format!( - r#"
-

Enroll this device?

-
Label{label}
-
Pubkey{short}…
-

- Confirming adds this X25519 public key to the trust list. Every - DD agent mirrors that list within 30 s; thereafter, a client - holding the matching private key can open Noise_IK sessions to - any enclave in the fleet. Revoke any time with - DELETE /api/v1/devices/<pubkey>. -

-

-
- - Cancel -
-
-"#, - label = html::escape(&label), - short = html::escape(short), - pubkey_js = serde_json::to_string(&pubkey).unwrap_or_else(|_| "\"\"".into()), - label_js = serde_json::to_string(&label).unwrap_or_else(|_| "\"\"".into()), + let agents = s.store.lock().await; + let Some(agent) = agents.values().find(|a| { + a.status == "healthy" + && a.agent_mode == AgentMode::ReadWrite + && a.agent_id != "control-plane" + }) else { + return Html(shell( + "Enroll device", + "", + r#"

No read-write agent available

Try again after an agent registers.

"#, + )) + .into_response(); + }; + let url = format!( + "https://{}/admin/enroll?pubkey={}&label={}", + agent.hostname, + urlencoding::encode(&pubkey), + urlencoding::encode(&label), ); - - Html(shell("Enroll device", "", &body)).into_response() + Redirect::temporary(&url).into_response() } /// GET /api/agents — JSON list of diff --git a/src/devices.rs b/src/devices.rs index baa6f27..3b9110a 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -1,11 +1,12 @@ //! Device pubkey registry. //! -//! Holds the X25519 pubkeys of paired client devices. Source of truth -//! lives on the CP's disk at [`Store::path`] (JSON, pretty-printed for -//! human editability in a pinch). The live set of *non-revoked* -//! pubkeys is also mirrored into a [`noise_gateway::TrustHandle`] so -//! the locally-running Noise gateway can read it directly from shared -//! memory — no on-disk runtime view, no cross-process file contract. +//! Holds the X25519 pubkeys of paired client devices. The store lives +//! next to the process that enforces trust, normally `dd-agent` +//! (JSON, pretty-printed for human editability in a pinch). The live +//! set of *non-revoked* pubkeys is mirrored into a +//! [`noise_gateway::TrustHandle`] so the locally-running Noise gateway +//! can read it directly from shared memory — no on-disk runtime view, +//! no cross-process file contract. //! //! Wire format on disk: //! ```json diff --git a/src/noise_gateway/mod.rs b/src/noise_gateway/mod.rs index fc45976..bfa0c63 100644 --- a/src/noise_gateway/mod.rs +++ b/src/noise_gateway/mod.rs @@ -32,8 +32,7 @@ use axum::Router; use tokio::sync::RwLock; /// Live set of device pubkeys the local Noise responder will accept. -/// Mutated by `devices::Store` (on the CP) or by the agent's -/// `sync_trusted_devices` poll loop. +/// Mutated by the local `devices::Store` next to the process enforcing trust. pub type TrustHandle = Arc>>; pub fn new_trust_handle() -> TrustHandle { From 35f97504d775414f138e8cc50a5ec2d5a5c3aa83 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:55:36 +0000 Subject: [PATCH 07/13] Remove CP Noise gateway surface --- docs/sessiond-central-ui-plan.md | 6 ++-- src/cf.rs | 4 +-- src/config.rs | 7 ----- src/cp.rs | 48 ++------------------------------ 4 files changed, 5 insertions(+), 60 deletions(-) diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index ce80db3..f6b2e3b 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -156,8 +156,8 @@ Implemented first slice: session into encrypted raw PTY byte streaming to local `dd-sessiond`. - Native `exec`, `replay`, `resize`, and `close` commands exercise the same direct Noise path as `shell`. -- CP Noise endpoints reject shell methods because they have no local sessiond - adapter; agent Noise endpoints wire the adapter in. +- CP does not run the client Noise gateway. Agent Noise endpoints wire the + sessiond adapter in. - The native CLI appraises the agent quote with Intel Trust Authority by default and requires an explicit insecure flag for local preview/dev. @@ -202,8 +202,6 @@ direct Noise, remove the old shell stack in this order: Keep these pieces: - `dd-sessiond` and its local-only API. It is the session owner, not a fallback. -- `shell_unavailable` on CP Noise endpoints. It is an explicit rejection for a - process that intentionally has no local sessiond. - CP route discovery and enrollment brokering. CP stays in the trust/control path, not the shell data path or shell state path. diff --git a/src/cf.rs b/src/cf.rs index a827a59..c0b4ce1 100644 --- a/src/cf.rs +++ b/src/cf.rs @@ -484,9 +484,8 @@ async fn delete_access_apps_for_domains( /// - `{hostname}` — dashboard, app-layer GitHub auth /// - `{hostname}-shell` — dd-shell, app-layer GitHub auth /// - `{hostname}/health` — public (read-only fleet health; -/// also carries the Noise pre-handshake `{quote_b64, pubkey_hex}`) +/// CP does not expose shell/client Noise) /// - `{hostname}/api/agents` — read-only agent list -/// - `{hostname}/noise/ws` — Noise_IK-gated in code /// - `{hostname}/register` — ITA-gated in code /// - `{hostname}/ingress/replace` — ITA-gated in code pub async fn delete_cp_access_apps( @@ -507,7 +506,6 @@ pub async fn delete_cp_access_apps( "/health", "/api/agents", "/api/v1/admin/export", - "/noise/ws", "/register", "/ingress/replace", // Legacy route from the old two-step Noise pre-handshake. diff --git a/src/config.rs b/src/config.rs index cdb0e4e..8a107cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,9 +168,6 @@ pub struct Cp { pub scraper_shard_index: u64, pub scraper_shard_total: u64, pub ita: Ita, - /// Where the Noise gateway persists its X25519 static private key - /// (tmpfs). Fresh per-boot when missing. - pub noise_key_path: std::path::PathBuf, } impl Cp { @@ -204,9 +201,6 @@ impl Cp { "DD_SCRAPER_SHARD_INDEX ({scraper_shard_index}) must be less than DD_SCRAPER_SHARD_TOTAL ({scraper_shard_total})" ))); } - let noise_key_path = std::env::var("DD_NOISE_KEY_PATH") - .unwrap_or_else(|_| "/run/devopsdefender/noise.key".into()) - .into(); let ita = Ita::from_env(&common.env_label)?; Ok(Self { common, @@ -218,7 +212,6 @@ impl Cp { scraper_shard_index, scraper_shard_total, ita, - noise_key_path, }) } } diff --git a/src/cp.rs b/src/cp.rs index a9d70f4..52e2143 100644 --- a/src/cp.rs +++ b/src/cp.rs @@ -29,7 +29,6 @@ use crate::error::{Error, Result}; use crate::html::{self, shell}; use crate::ita; use crate::metrics; -use crate::noise_gateway; use crate::stonith; use crate::taint::IntegrityState; use crate::units::{AgentMode, UnitKind}; @@ -54,13 +53,6 @@ struct St { /// GH OIDC verifier for `/api/agents` callers (CI, humans). Same /// audience as dd-agent, shared owner claim. gh: Arc, - /// TDX-quote + Noise-static-pubkey bundle. Surfaced by `/health` - /// as `{ noise: { quote_b64, pubkey_hex } }` so a bastion-app - /// bootstraps in one fetch (the former standalone `/attest` - /// endpoint was folded in). Shared `Arc` with the Noise gateway - /// module's handshake responder — one keypair / one quote per - /// boot. - attest: Arc, } pub async fn run() -> Result<()> { @@ -135,7 +127,6 @@ pub async fn run() -> Result<()> { // safe to run unconditionally — it doesn't touch the poisonable // hostname at all. let store: Store = Arc::new(Mutex::new(HashMap::new())); - let trust = noise_gateway::new_trust_handle(); let predecessor_prefix = cf::cp_prefix(&cfg.common.env_label); let has_predecessor = match cf::list(&http, &cfg.cf).await { Ok(tunnels) => tunnels.iter().any(|t| { @@ -330,26 +321,6 @@ pub async fn run() -> Result<()> { let gh = crate::gh_oidc::Verifier::new(cfg.common.owner.clone(), "dd-agent".into()); - // CP serves its attested Noise key for management methods, but it does not - // own paired-device trust for shell sessions. - let attestor = Arc::new( - noise_gateway::attest::Attestor::load_or_mint(&cfg.noise_key_path) - .await - .map_err(|e| Error::Internal(format!("noise keypair: {e}")))?, - ); - eprintln!("cp: noise_pubkey={}", hex::encode(attestor.public_key())); - let ee_token = std::env::var("EE_TOKEN").ok(); - let upstream = Arc::new(noise_gateway::upstream::EeAgent::new( - std::path::PathBuf::from(noise_gateway::upstream::DEFAULT_EE_AGENT_SOCK), - ee_token, - )); - let ng_state = noise_gateway::State { - attest: attestor.clone(), - trust: trust.clone(), - upstream, - shell: None, - }; - let state = St { cfg: cfg.clone(), ee, @@ -359,7 +330,6 @@ pub async fn run() -> Result<()> { verifier, cp_ita_token, gh, - attest: attestor, }; let app = Router::new() @@ -374,8 +344,7 @@ pub async fn run() -> Result<()> { .route("/api/agents", get(api_agents)) .route("/api/v1/admin/export", get(export_state)) .route("/admin/enroll", get(enroll_page)) - .with_state(state) - .merge(noise_gateway::router(ng_state)); + .with_state(state); let addr = format!("0.0.0.0:{}", cfg.common.port); eprintln!("cp: listening on {addr}"); @@ -484,7 +453,6 @@ async fn health( State(s): State, Query(q): Query>, ) -> Json { - use base64::Engine as _; let agents = s.store.lock().await; let mut body = serde_json::json!({ "ok": true, @@ -496,21 +464,9 @@ async fn health( "agent_count": agents.len(), "healthy_count": agents.values().filter(|a| a.status == "healthy").count(), "oracle_count": agents.values().map(|a| a.oracles.len()).sum::(), - // Pre-Noise-handshake bundle — the former `GET /attest` - // endpoint folded in here so bastion-app bootstraps in one - // fetch and keep Cloudflare routing app count lower. Stable - // per boot; `Arc` clones are effectively free - // per request. `quote_b64` binds the raw Noise pubkey into - // TDX `report_data`, self-authenticating via ITA. - "noise": { - "quote_b64": base64::engine::general_purpose::STANDARD.encode(s.attest.quote()), - "pubkey_hex": hex::encode(s.attest.public_key()), - }, }); // `?verbose=1` folds in the CP's current ITA token so operators - // can inspect the CP VM's TDX measurement without a second route - // (the old `/cp/ita` + `/cp/attest` paths were removed; the - // TDX quote for the Noise pubkey is also above, unconditionally). + // can inspect the CP VM's TDX measurement without a second route. if q.get("verbose").map(|v| v.as_str()) == Some("1") { if let Some(obj) = body.as_object_mut() { obj.insert( From f1d56a6542154a98d10243fa30b10c213b7add4b Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 13:56:58 +0000 Subject: [PATCH 08/13] Require sessiond for Noise shell methods --- src/agent.rs | 2 +- src/noise_gateway/mod.rs | 2 +- src/noise_gateway/noise.rs | 20 ++------------------ 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 9f92f5f..a35c87c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -192,7 +192,7 @@ pub async fn run() -> Result<()> { attest: attestor.clone(), trust, upstream, - shell: Some(shell), + shell, }; let gh = gh_oidc::Verifier::new(cfg.common.owner.clone(), "dd-agent".into()); diff --git a/src/noise_gateway/mod.rs b/src/noise_gateway/mod.rs index bfa0c63..6e2f2ae 100644 --- a/src/noise_gateway/mod.rs +++ b/src/noise_gateway/mod.rs @@ -44,7 +44,7 @@ pub struct State { pub attest: Arc, pub trust: TrustHandle, pub upstream: Arc, - pub shell: Option>, + pub shell: Arc, } pub fn router(state: State) -> Router { diff --git a/src/noise_gateway/noise.rs b/src/noise_gateway/noise.rs index 887c1f7..cde4f37 100644 --- a/src/noise_gateway/noise.rs +++ b/src/noise_gateway/noise.rs @@ -123,15 +123,7 @@ async fn handle(mut socket: WebSocket, state: AppState) -> anyhow::Result<()> { } } Ok(allowlist::Method::ShellAttachSession) => { - let Some(shell) = &state.shell else { - let resp = serde_json::json!({ - "error": "shell_unavailable", - "detail": "this Noise endpoint is not connected to sessiond", - }); - send_encrypted_json(&mut transport, &mut socket, &resp).await?; - continue; - }; - match shell.attach_stream(request).await { + match state.shell.attach_stream(request).await { Ok((ack, stream)) => { send_encrypted_json(&mut transport, &mut socket, &ack).await?; bridge_attach(&mut transport, &mut socket, stream).await?; @@ -155,15 +147,7 @@ async fn handle(mut socket: WebSocket, state: AppState) -> anyhow::Result<()> { | allowlist::Method::ShellReplaySession | allowlist::Method::ShellResizeSession, ) => { - let Some(shell) = &state.shell else { - let resp = serde_json::json!({ - "error": "shell_unavailable", - "detail": "this Noise endpoint is not connected to sessiond", - }); - send_encrypted_json(&mut transport, &mut socket, &resp).await?; - continue; - }; - let response = shell.call(request).await.unwrap_or_else(|e| { + let response = state.shell.call(request).await.unwrap_or_else(|e| { serde_json::json!({ "error": "shell_failed", "detail": e.to_string(), From 904f989b63e9207ea348ea13a7d04abd898d633d Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 14:08:43 +0000 Subject: [PATCH 09/13] Drop PWA shell direction --- README.md | 9 ++- apps/README.md | 7 +- docs/sessiond-central-ui-plan.md | 59 +++++++++-------- src/shell.rs | 110 +------------------------------ 4 files changed, 39 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 2988c0c..d5010eb 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,10 @@ The CLI uses `DD_ITA_API_KEY` for quote appraisal. `DD_ITA_BASE_URL`, production endpoints and can be overridden when needed. Local preview/dev runs without ITA credentials must pass `--insecure-skip-quote-verify` explicitly. -The web shell should become another client implementation of the same protocol: -it keeps its own paired device identity, asks CP for current routes, and opens -Noise directly to the agent. The existing cookie-auth browser shell is -transitional compatibility only; new shell features should land on the Noise -client protocol. +The native CLI is the protocol reference for the future desktop/mobile app. The +browser remains dashboard and enrollment UI only; the existing cookie-auth +browser shell is transitional compatibility while the native app takes over +shell/session workflows. ## STONITH diff --git a/apps/README.md b/apps/README.md index 19795ce..545c0fe 100644 --- a/apps/README.md +++ b/apps/README.md @@ -132,7 +132,8 @@ The shell UI treats both as terminal views, but only read-write sessions get WebSocket input, resize, and close controls. Workloads do not opt into read-write access by putting metadata in `workload.json`; the boundary is the session protocol exposed by `dd-agent` over Noise. The current browser shell -HTTP/WebSocket APIs are compatibility only while web/PWA moves to direct Noise. +HTTP/WebSocket APIs are compatibility only while the native app takes over +shell/session workflows. Internally DD may still call this taint tracking, but the API/UI should speak in integrity terms: clean for observed-only logs, controlled for interactive PTYs or other human control paths. @@ -145,8 +146,8 @@ printf '\033]9;%s\033\\' 'job finished' printf '\033]777;notify;%s;%s\033\\' 'oracle' 'new result available' ``` -For mobile web, this is the first step toward a PWA-style shell inbox: read-only -workload cards, read-write Codex/Claude session cards, and push-backed +The native desktop/mobile app is the target for shell inbox workflows: +read-only workload cards, read-write Codex/Claude session cards, and notifications for long-running jobs. Per-workload ingress is **boot-time only** today. Workloads POSTed later via diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index f6b2e3b..cdd9d21 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -1,4 +1,4 @@ -# Sessiond + Central UI Plan +# Sessiond + Native Client Plan ## Goal @@ -9,7 +9,7 @@ dogfood VM or killing active Codex/Claude sessions. ## Target Shape ```text -native/web/mobile client +native desktop/mobile client or CLI -> CP route discovery + enrollment broker -> selected agent /noise/ws -> dd-sessiond Unix socket on that VM @@ -28,7 +28,7 @@ Agent VM responsibilities: Control-plane responsibilities: - Broker device enrollment and own route discovery. -- Optionally serve static web/PWA assets. +- Serve dashboards and enrollment broker pages. - Never carry shell, log, transcript, or PTY bytes. Client responsibilities: @@ -44,8 +44,8 @@ Client responsibilities: - No fallback in-process PTY mode once `dd-sessiond` is introduced. - No CRIU/checkpoint work in the first implementation. - No CP relay or mailbox relay for shell/log/session bytes. -- No full browser-side Noise handshake in the first implementation; native CLI - is the first protocol exerciser. +- No browser/PWA shell client. Browser stays dashboard/enrollment only; the + session client is native app/CLI. ## Phase 1: One Disruptive Dogfood Upgrade @@ -99,33 +99,33 @@ Implementation notes: - The existing `/api/sessions*` and `/ws/sessions*` browser-shell routes are transitional compatibility only. Do not add new client features there. -## Phase 3: Client-Side Fleet UI +## Phase 3: Native Fleet Client -Move active shell UI development to clients that use CP only for routes and -agent/device policy. +Move active shell UI development to a native client that uses CP only for +routes and enrollment brokering. Deliverables: - Add CP route discovery for agent sessions. -- UI lists sessions for the selected agent. -- UI can create, attach, input, resize, close, and replay sessions by opening - Noise directly to the selected agent. +- Native app lists sessions for the selected agent. +- Native app can create, attach, input, resize, close, and replay sessions by + opening Noise directly to the selected agent. - Desktop terminal view remains raw PTY-first. -- Mobile view can add touch controls, smart sizing, and assistant-style - rendering without changing agent-side session ownership. -- The web/PWA interface becomes a client implementation of the same protocol, - not a server-side shell proxy. +- Mobile app can add touch controls, smart sizing, notifications, and + assistant-style rendering without changing agent-side session ownership. +- Browser dashboard links users to the native app/CLI enrollment and route + discovery flows; it does not become a terminal/PWA client. Acceptance: -- Updating static web/PWA assets updates the browser shell/mobile experience. -- No dogfood agent restart is needed for UI-only changes. +- Updating the native app updates shell/mobile experience without changing the + agent/sessiond data plane. - Active dogfood Codex sessions survive web/client updates. Status: - Not implemented in the first branch slice. The native CLI is present so the - client-side protocol can be tested before replacing the browser shell proxy. + client protocol can be tested before replacing the browser shell proxy. ## Phase 4: Auth, Attestation, And Noise @@ -140,8 +140,7 @@ Start simple: Then strengthen: - Clients verify the agent quote and pin the attested Noise public key. -- Browser/PWA uses the same direct agent Noise path once the browser crypto or - WASM client is in place. +- Native desktop/mobile apps reuse the same direct agent Noise path as the CLI. - Pairing survives CP redeploys without putting shell/session state in CP. A paired native/web/mobile client must not need to re-pair just because preview or production CP was relaunched. @@ -177,21 +176,22 @@ Now that the durable session owner is `dd-sessiond` and native clients can use direct Noise, remove the old shell stack in this order: 1. Freeze cookie-auth browser shell APIs. Treat `src/shell.rs` routes - `/api/sessions*` and `/ws/sessions*` as compatibility only until the web/PWA - client speaks Noise directly. + `/api/sessions*` and `/ws/sessions*` as compatibility only until the native + app covers the workflow. 2. Remove old env compatibility names. `DD_SESSIOND_HISTORY_KEY` is the only transcript-key override; do not continue accepting `DD_SHELL_HISTORY_KEY`. 3. Fix pairing durability without making CP a shell/session state owner. CP can broker enrollment, but durable paired-device trust must live with the enforcement point or an explicitly chosen non-CP store. -4. Move web/PWA to direct Noise. Store a paired device key in browser storage, - use CP only for enrollment and route discovery, then connect to the selected - agent `/noise/ws` for session RPCs and PTY bytes. +4. Extract the native Noise client into a reusable app library. Store paired + device keys in OS secure storage, use CP only for enrollment and route + discovery, then connect to the selected agent `/noise/ws` for session RPCs + and PTY bytes. 5. Delete server-side browser shell proxying. Remove `src/shell.rs` session - proxy routes and WebSocket attach path once the web/PWA client uses direct - Noise. Keep only static asset serving if needed. + proxy routes and WebSocket attach path once the native app covers + create/attach/replay/resize/close. 6. Delete agent HTTP session proxying. Remove `/api/sessions*` from `dd-agent` - once native and web clients both use Noise for session control. + once native clients use Noise for session control. 7. Retire legacy combined shell workloads. Remove `apps/confidential-shell` and `apps/codex-podman-shell` after deploy templates and docs no longer point at `DD_MODE=shell` as a PTY owner. @@ -214,6 +214,7 @@ Keep these pieces: passing or exec handoff. - How much of the existing transcript encryption format should move unchanged into `dd-sessiond`. -- Browser Noise implementation choice: pure JS library versus small WASM client. - Where durable paired-device trust should live if CP only brokers enrollment and route discovery. +- Native app shell: Tauri/Rust shell versus platform-native UI around the Rust + Noise client library. diff --git a/src/shell.rs b/src/shell.rs index c799614..4f46a18 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -115,9 +115,6 @@ pub async fn run() -> Result<()> { let app = Router::new() .route("/", get(index)) .route("/favicon.ico", get(favicon)) - .route("/manifest.webmanifest", get(manifest)) - .route("/sw.js", get(service_worker)) - .route("/icon.svg", get(icon_svg)) .route("/assets/xterm/xterm.css", get(xterm_css)) .route("/assets/xterm/xterm.js", get(xterm_js)) .route("/assets/xterm/addon-fit.js", get(xterm_fit_js)) @@ -176,82 +173,6 @@ async fn favicon() -> StatusCode { StatusCode::NO_CONTENT } -async fn manifest() -> impl IntoResponse { - ( - [ - ("content-type", "application/manifest+json; charset=utf-8"), - ("cache-control", "no-cache"), - ], - r##"{ - "name": "DD Shell", - "short_name": "DD Shell", - "description": "DevOps Defender confidential shell", - "start_url": "/", - "scope": "/", - "display": "standalone", - "background_color": "#05070a", - "theme_color": "#111520", - "icons": [ - {"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"} - ] -}"##, - ) -} - -async fn service_worker() -> impl IntoResponse { - ( - [ - ("content-type", "application/javascript; charset=utf-8"), - ("cache-control", "no-cache"), - ], - r#"self.addEventListener("install", event => { - event.waitUntil(self.skipWaiting()); -}); -self.addEventListener("activate", event => { - event.waitUntil(self.clients.claim()); -}); -self.addEventListener("notificationclick", event => { - event.notification.close(); - event.waitUntil((async () => { - const allClients = await self.clients.matchAll({type: "window", includeUncontrolled: true}); - if (allClients.length) { - await allClients[0].focus(); - return; - } - await self.clients.openWindow("/"); - })()); -}); -self.addEventListener("message", event => { - const data = event.data || {}; - if (data.type !== "notify") return; - const title = data.title || "DD Shell"; - const body = data.body || ""; - event.waitUntil(self.registration.showNotification(title, { - body, - tag: data.tag || "dd-shell", - renotify: true, - icon: "/icon.svg", - badge: "/icon.svg" - })); -}); -"#, - ) -} - -async fn icon_svg() -> impl IntoResponse { - ( - [ - ("content-type", "image/svg+xml; charset=utf-8"), - ("cache-control", "public, max-age=31536000, immutable"), - ], - r##" - - - -"##, - ) -} - async fn xterm_css() -> impl IntoResponse { ( [ @@ -886,9 +807,6 @@ let promptRequests = loadPromptHistory(); let promptScanBuffer = ""; let lastPromptKey = ""; const decoder = new TextDecoder(); -let serviceWorkerReady = null; -installPwaMetadata(); -registerServiceWorker(); async function api(path, opts) { const res = await fetch(path, opts); @@ -1021,8 +939,7 @@ function notificationHtml() { const supported = "Notification" in window; const permission = supported ? Notification.permission : "unavailable"; const enabled = supported && permission === "granted" && notifyMode !== "off"; - const sw = "serviceWorker" in navigator ? "service worker ready" : "service worker unavailable"; - const detail = supported ? `native ${permission}; ${sw}; in-app history on` : `in-app history on; native unavailable; ${sw}`; + const detail = supported ? `native ${permission}; in-app history on` : "in-app history on; native unavailable"; return `
Notifications${enabled ? "native" : "in-app"}
${escapeHtml(detail)}
`; } @@ -1173,7 +1090,6 @@ document.getElementById("close").onclick = async () => { await refresh(); }; document.getElementById("notify").onclick = async () => { - await registerServiceWorker(); if (!("Notification" in window)) { document.getElementById("status").textContent = "Notifications unavailable"; return; @@ -1445,39 +1361,15 @@ function notify(title, body) { showToast(item); deliverNativeNotification(item); } -function registerServiceWorker() { - if (!("serviceWorker" in navigator) || !window.isSecureContext) return Promise.resolve(null); - if (!serviceWorkerReady) { - serviceWorkerReady = navigator.serviceWorker.register("/sw.js") - .then(() => navigator.serviceWorker.ready) - .catch(() => null); - } - return serviceWorkerReady; -} async function deliverNativeNotification(item) { if (!("Notification" in window) || Notification.permission !== "granted") return; if (notifyMode !== "always" && document.hasFocus()) return; const title = item.title || "DD Shell"; const body = item.body || ""; const tag = current ? `dd-shell-${current}` : "dd-shell"; - const registration = await registerServiceWorker(); - if (registration && "showNotification" in registration) { - await registration.showNotification(title, {body, tag, renotify: true, icon: "/icon.svg", badge: "/icon.svg"}); - return; - } const n = new Notification(title, {body, tag}); n.onclick = () => { window.focus(); term.focus(); n.close(); }; } -function installPwaMetadata() { - const manifest = document.createElement("link"); - manifest.rel = "manifest"; - manifest.href = "/manifest.webmanifest"; - document.head.appendChild(manifest); - const theme = document.createElement("meta"); - theme.name = "theme-color"; - theme.content = "#111520"; - document.head.appendChild(theme); -} function loadNotificationHistory() { try { const parsed = JSON.parse(localStorage.getItem("dd-shell-notifications") || "[]"); From 8269a61473f11894a07aee078670e900f5715c0a Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 14:53:52 +0000 Subject: [PATCH 10/13] Remove bundled Noise client --- Cargo.lock | 20 +- Cargo.toml | 3 +- README.md | 18 +- docs/sessiond-central-ui-plan.md | 23 +- src/lib.rs | 1 - src/main.rs | 6 +- src/noise_client.rs | 721 ------------------------------- 7 files changed, 19 insertions(+), 773 deletions(-) delete mode 100644 src/noise_client.rs diff --git a/Cargo.lock b/Cargo.lock index 55875bf..865fe94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,7 +386,6 @@ dependencies = [ "tempfile", "thiserror", "tokio", - "tokio-tungstenite", "urlencoding", "uuid", "x25519-dalek", @@ -756,7 +755,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots", ] [[package]] @@ -1449,7 +1448,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots", ] [[package]] @@ -1909,12 +1908,8 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", - "rustls", - "rustls-pki-types", "tokio", - "tokio-rustls", "tungstenite", - "webpki-roots 0.26.11", ] [[package]] @@ -2001,8 +1996,6 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -2235,15 +2228,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index ccb2218..cd7cf19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,7 @@ sha2 = "0.10" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } sysinfo = { version = "0.33", default-features = false, features = ["system", "disk", "network"] } thiserror = "2" -tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "io-std", "sync"] } -tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "sync"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/README.md b/README.md index d5010eb..23c6230 100644 --- a/README.md +++ b/README.md @@ -126,21 +126,9 @@ inside the Noise transport to the agent and then to local `dd-sessiond`; the CP is used for enrollment brokering and route discovery, not for shell/log/session bytes or paired-device trust storage. -The bundled native CLI exercises that path directly: - -```bash -devopsdefender noise keygen --cp-url https://app.devopsdefender.com --label laptop -devopsdefender noise recipes --url https:// -devopsdefender noise shell --url https:// --recipe shell -``` - -The CLI uses `DD_ITA_API_KEY` for quote appraisal. `DD_ITA_BASE_URL`, -`DD_ITA_JWKS_URL`, and `DD_ITA_ISSUER` default to Intel Trust Authority's -production endpoints and can be overridden when needed. Local preview/dev runs -without ITA credentials must pass `--insecure-skip-quote-verify` explicitly. - -The native CLI is the protocol reference for the future desktop/mobile app. The -browser remains dashboard and enrollment UI only; the existing cookie-auth +Client implementations live outside this repo, starting with the planned +`dd-client` repo that will contain the shared client core, CLI, and native app. +The browser remains dashboard and enrollment UI only; the existing cookie-auth browser shell is transitional compatibility while the native app takes over shell/session workflows. diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index cdd9d21..3395b0e 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -46,6 +46,8 @@ Client responsibilities: - No CP relay or mailbox relay for shell/log/session bytes. - No browser/PWA shell client. Browser stays dashboard/enrollment only; the session client is native app/CLI. +- No bundled client CLI in `dd`. Client core, CLI, and native app live in a + separate `dd-client` repo. ## Phase 1: One Disruptive Dogfood Upgrade @@ -134,13 +136,14 @@ Start simple: - CP brokers enrollment by redirecting to the selected agent and exposes current routes. - Agents hold the trusted device set they enforce for direct Noise sessions. -- Native CLI/desktop/mobile clients use direct `/noise/ws` channels for +- Native CLI/desktop/mobile clients from `dd-client` use direct `/noise/ws` channels for session RPCs. Then strengthen: - Clients verify the agent quote and pin the attested Noise public key. -- Native desktop/mobile apps reuse the same direct agent Noise path as the CLI. +- Native desktop/mobile apps reuse the same direct agent Noise path as the + `dd-client` CLI. - Pairing survives CP redeploys without putting shell/session state in CP. A paired native/web/mobile client must not need to re-pair just because preview or production CP was relaunched. @@ -153,12 +156,8 @@ Implemented first slice: `shell.close_session` JSON requests over Noise. - `shell.attach_session` returns one JSON ack, then switches the same Noise session into encrypted raw PTY byte streaming to local `dd-sessiond`. -- Native `exec`, `replay`, `resize`, and `close` commands exercise the same - direct Noise path as `shell`. - CP does not run the client Noise gateway. Agent Noise endpoints wire the sessiond adapter in. -- The native CLI appraises the agent quote with Intel Trust Authority by - default and requires an explicit insecure flag for local preview/dev. Design rule: @@ -183,10 +182,10 @@ direct Noise, remove the old shell stack in this order: 3. Fix pairing durability without making CP a shell/session state owner. CP can broker enrollment, but durable paired-device trust must live with the enforcement point or an explicitly chosen non-CP store. -4. Extract the native Noise client into a reusable app library. Store paired - device keys in OS secure storage, use CP only for enrollment and route - discovery, then connect to the selected agent `/noise/ws` for session RPCs - and PTY bytes. +4. Create `dd-client` with shared client core, CLI, and native app. Store + paired device keys in OS secure storage, use CP only for enrollment and + route discovery, then connect to the selected agent `/noise/ws` for session + RPCs and PTY bytes. 5. Delete server-side browser shell proxying. Remove `src/shell.rs` session proxy routes and WebSocket attach path once the native app covers create/attach/replay/resize/close. @@ -216,5 +215,5 @@ Keep these pieces: into `dd-sessiond`. - Where durable paired-device trust should live if CP only brokers enrollment and route discovery. -- Native app shell: Tauri/Rust shell versus platform-native UI around the Rust - Noise client library. +- Native app shell: Tauri/Rust shell versus platform-native UI around the + `dd-client` Rust core. diff --git a/src/lib.rs b/src/lib.rs index c4c8b01..7568ffc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,6 @@ pub mod gh_oidc; pub mod html; pub mod ita; pub mod metrics; -pub mod noise_client; pub mod noise_gateway; pub mod oracle; pub mod sessiond; diff --git a/src/main.rs b/src/main.rs index f131a99..9482ab1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,10 @@ //! DD_MODE=agent devopsdefender # in-VM agent //! DD_MODE=shell devopsdefender # shell web service //! DD_MODE=sessiond devopsdefender # local session supervisor -//! devopsdefender noise ... # native Noise client //! //! (Also accepts `devopsdefender cp` / `devopsdefender agent` for local dev.) -use devopsdefender::{agent, cp, noise_client, sessiond, shell}; +use devopsdefender::{agent, cp, sessiond, shell}; #[tokio::main] async fn main() { @@ -21,9 +20,8 @@ async fn main() { Some("agent") => agent::run().await.map_err(Into::into), Some("shell") => shell::run().await.map_err(Into::into), Some("sessiond") => sessiond::run().await.map_err(Into::into), - Some("noise") | Some("cli") => noise_client::run_cli().await, _ => { - eprintln!("usage: devopsdefender "); + eprintln!("usage: devopsdefender "); eprintln!(" or: DD_MODE= devopsdefender"); std::process::exit(2); } diff --git a/src/noise_client.rs b/src/noise_client.rs deleted file mode 100644 index d8d16ae..0000000 --- a/src/noise_client.rs +++ /dev/null @@ -1,721 +0,0 @@ -//! Native Noise client for operator shell sessions. -//! -//! This is intentionally small and dependency-light: it speaks the same -//! Noise_IK-over-WebSocket framing as `noise_gateway`, then sends the -//! shell/session RPCs that the gateway forwards to local `dd-sessiond`. - -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, Context}; -use base64::Engine as _; -use futures_util::{SinkExt, StreamExt}; -use rand::rngs::OsRng; -use serde_json::Value; -use snow::{Builder, TransportState}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::Message as WsMessage; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; -use x25519_dalek::{PublicKey, StaticSecret}; - -const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; -const MAX_NOISE_MSG: usize = 65535; -const ATTACH_CHUNK: usize = 4096; - -type WsStream = WebSocketStream>; -type WsSink = futures_util::stream::SplitSink; -type WsRead = futures_util::stream::SplitStream; - -pub async fn run_cli() -> anyhow::Result<()> { - let mut args: Vec = std::env::args().skip(2).collect(); - if args.is_empty() || args[0] == "-h" || args[0] == "--help" { - print_usage(); - return Ok(()); - } - - let command = args.remove(0); - match command.as_str() { - "keygen" => { - let opts = parse_opts(args)?; - let key_path = opts.key_path(); - let secret = load_or_create_key(&key_path).await?; - let pubkey = public_hex(&secret); - println!("{pubkey}"); - if let Some(cp_url) = opts.cp_url.as_deref() { - let label = opts.label.unwrap_or_else(default_label); - println!("{}", enrollment_url(cp_url, &pubkey, &label)); - } - } - "pubkey" => { - let opts = parse_opts(args)?; - let secret = load_or_create_key(&opts.key_path()).await?; - println!("{}", public_hex(&secret)); - } - "recipes" => { - let opts = parse_opts(args)?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({"method": "shell.list_recipes"})) - .await?, - )?; - } - "sessions" => { - let opts = parse_opts(args)?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({"method": "shell.list_sessions"})) - .await?, - )?; - } - "create" => { - let opts = parse_opts(args)?; - let mut conn = connect(&opts).await?; - let session = create_session(&mut conn, &opts).await?; - print_json(session)?; - } - "replay" => { - let opts = parse_opts(args)?; - let id = opts - .id - .as_deref() - .ok_or_else(|| anyhow!("replay requires --id"))?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({ - "method": "shell.replay_session", - "id": id, - })) - .await?, - )?; - } - "resize" => { - let opts = parse_opts(args)?; - let id = opts - .id - .as_deref() - .ok_or_else(|| anyhow!("resize requires --id"))?; - let cols = opts.cols.ok_or_else(|| anyhow!("resize requires --cols"))?; - let rows = opts.rows.ok_or_else(|| anyhow!("resize requires --rows"))?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({ - "method": "shell.resize_session", - "id": id, - "cols": cols, - "rows": rows, - })) - .await?, - )?; - } - "close" => { - let opts = parse_opts(args)?; - let id = opts - .id - .as_deref() - .ok_or_else(|| anyhow!("close requires --id"))?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({ - "method": "shell.close_session", - "id": id, - })) - .await?, - )?; - } - "attach" => { - let opts = parse_opts(args)?; - let id = opts - .id - .as_deref() - .ok_or_else(|| anyhow!("attach requires --id"))?; - let conn = connect(&opts).await?; - attach_session(conn, id).await?; - } - "shell" => { - let opts = parse_opts(args)?; - let mut conn = connect(&opts).await?; - let session = create_session(&mut conn, &opts).await?; - let id = session_id(&session)?; - attach_session(conn, &id).await?; - } - "exec" => { - let (opts, cmd) = parse_exec_opts(args)?; - let mut conn = connect(&opts).await?; - print_json( - conn.call(serde_json::json!({ - "method": "exec", - "cmd": cmd, - "timeout_secs": opts.timeout_secs.unwrap_or(60), - })) - .await?, - )?; - } - other => { - anyhow::bail!("unknown noise command `{other}`"); - } - } - Ok(()) -} - -fn print_usage() { - eprintln!( - "usage: - devopsdefender noise keygen [--key PATH] [--cp-url URL] [--label LABEL] - devopsdefender noise pubkey [--key PATH] - devopsdefender noise recipes --url AGENT_URL [--key PATH] - devopsdefender noise sessions --url AGENT_URL [--key PATH] - devopsdefender noise create --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH] - devopsdefender noise replay --url AGENT_URL [--key PATH] --id SESSION_ID - devopsdefender noise resize --url AGENT_URL [--key PATH] --id SESSION_ID --cols N --rows N - devopsdefender noise close --url AGENT_URL [--key PATH] --id SESSION_ID - devopsdefender noise attach --url AGENT_URL [--key PATH] --id SESSION_ID - devopsdefender noise shell --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH] - devopsdefender noise exec --url AGENT_URL [--key PATH] [--timeout SECS] -- CMD [ARG...] - -Quote verification is enabled by default and uses DD_ITA_API_KEY plus optional -DD_ITA_BASE_URL, DD_ITA_JWKS_URL, and DD_ITA_ISSUER. Local dev can pass ---insecure-skip-quote-verify explicitly." - ); -} - -#[derive(Default)] -struct Opts { - url: Option, - key: Option, - cp_url: Option, - label: Option, - recipe: Option, - name: Option, - command: Option, - id: Option, - cols: Option, - rows: Option, - timeout_secs: Option, - insecure_skip_quote_verify: bool, - ita_api_key: Option, - ita_base_url: Option, - ita_jwks_url: Option, - ita_issuer: Option, -} - -impl Opts { - fn key_path(&self) -> PathBuf { - self.key - .clone() - .or_else(|| std::env::var_os("DD_NOISE_CLIENT_KEY").map(PathBuf::from)) - .unwrap_or_else(default_key_path) - } - - fn agent_url(&self) -> anyhow::Result<&str> { - self.url - .as_deref() - .ok_or_else(|| anyhow!("missing --url AGENT_URL")) - } -} - -fn parse_opts(args: Vec) -> anyhow::Result { - let mut opts = Opts::default(); - let mut i = 0; - while i < args.len() { - let key = args[i].as_str(); - i += 1; - let mut take = |name: &str| -> anyhow::Result { - let value = args - .get(i) - .cloned() - .ok_or_else(|| anyhow!("{name} requires a value"))?; - i += 1; - Ok(value) - }; - match key { - "--url" => opts.url = Some(take("--url")?), - "--key" => opts.key = Some(PathBuf::from(take("--key")?)), - "--cp-url" => opts.cp_url = Some(take("--cp-url")?), - "--label" => opts.label = Some(take("--label")?), - "--recipe" => opts.recipe = Some(take("--recipe")?), - "--name" => opts.name = Some(take("--name")?), - "--command" => opts.command = Some(take("--command")?), - "--id" => opts.id = Some(take("--id")?), - "--cols" => opts.cols = Some(parse_u64("--cols", &take("--cols")?)?), - "--rows" => opts.rows = Some(parse_u64("--rows", &take("--rows")?)?), - "--timeout" => opts.timeout_secs = Some(parse_u64("--timeout", &take("--timeout")?)?), - "--ita-api-key" => opts.ita_api_key = Some(take("--ita-api-key")?), - "--ita-base-url" => opts.ita_base_url = Some(take("--ita-base-url")?), - "--ita-jwks-url" => opts.ita_jwks_url = Some(take("--ita-jwks-url")?), - "--ita-issuer" => opts.ita_issuer = Some(take("--ita-issuer")?), - "--insecure-skip-quote-verify" => opts.insecure_skip_quote_verify = true, - "-h" | "--help" => { - print_usage(); - std::process::exit(0); - } - other => anyhow::bail!("unknown option `{other}`"), - } - } - Ok(opts) -} - -fn parse_exec_opts(args: Vec) -> anyhow::Result<(Opts, Vec)> { - let split = args - .iter() - .position(|arg| arg == "--") - .ok_or_else(|| anyhow!("exec requires `-- CMD [ARG...]`"))?; - let opts = parse_opts(args[..split].to_vec())?; - let cmd = args[split + 1..].to_vec(); - if cmd.is_empty() { - anyhow::bail!("exec requires a command after `--`"); - } - Ok((opts, cmd)) -} - -fn parse_u64(name: &str, value: &str) -> anyhow::Result { - value - .parse() - .with_context(|| format!("{name} must be an unsigned integer")) -} - -struct NoiseConnection { - transport: TransportState, - sink: WsSink, - stream: WsRead, -} - -impl NoiseConnection { - async fn call(&mut self, request: Value) -> anyhow::Result { - let plain = serde_json::to_vec(&request)?; - send_encrypted(&mut self.transport, &mut self.sink, &plain).await?; - let cipher = next_binary(&mut self.stream) - .await? - .ok_or_else(|| anyhow!("Noise websocket closed before response"))?; - let mut out = vec![0u8; cipher.len()]; - let n = self.transport.read_message(&cipher, &mut out)?; - out.truncate(n); - Ok(serde_json::from_slice(&out)?) - } -} - -async fn connect(opts: &Opts) -> anyhow::Result { - let base_url = opts.agent_url()?; - let secret = load_or_create_key(&opts.key_path()).await?; - let server_pubkey = fetch_and_verify_server_pubkey(base_url, opts).await?; - let ws_url = noise_ws_url(base_url); - - let (ws, _response) = connect_async(&ws_url) - .await - .with_context(|| format!("connect {ws_url}"))?; - let (mut sink, mut stream) = ws.split(); - - let mut hs = Builder::new(NOISE_PATTERN.parse()?) - .local_private_key(secret.as_bytes()) - .remote_public_key(&server_pubkey) - .build_initiator()?; - - let mut first = [0u8; MAX_NOISE_MSG]; - let n = hs.write_message(&[], &mut first)?; - sink.send(WsMessage::Binary(first[..n].to_vec().into())) - .await?; - - let second = next_binary(&mut stream) - .await? - .ok_or_else(|| anyhow!("Noise websocket closed during handshake"))?; - let mut payload = [0u8; MAX_NOISE_MSG]; - hs.read_message(&second, &mut payload)?; - - Ok(NoiseConnection { - transport: hs.into_transport_mode()?, - sink, - stream, - }) -} - -async fn fetch_and_verify_server_pubkey(base_url: &str, opts: &Opts) -> anyhow::Result<[u8; 32]> { - let url = health_url(base_url); - let body: Value = reqwest::get(&url) - .await - .with_context(|| format!("GET {url}"))? - .error_for_status() - .with_context(|| format!("GET {url}"))? - .json() - .await - .with_context(|| format!("parse {url}"))?; - let pubkey_hex = body - .pointer("/noise/pubkey_hex") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{url} did not include noise.pubkey_hex"))?; - let quote_b64 = body - .pointer("/noise/quote_b64") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{url} did not include noise.quote_b64"))?; - let bytes = hex::decode(pubkey_hex).context("decode noise.pubkey_hex")?; - if bytes.len() != 32 { - anyhow::bail!( - "noise.pubkey_hex decoded to {} bytes, expected 32", - bytes.len() - ); - } - let mut out = [0u8; 32]; - out.copy_from_slice(&bytes); - verify_quote_binding(quote_b64, &out, opts).await?; - Ok(out) -} - -async fn verify_quote_binding( - quote_b64: &str, - pubkey: &[u8; 32], - opts: &Opts, -) -> anyhow::Result<()> { - if opts.insecure_skip_quote_verify { - eprintln!("warning: skipping agent TDX quote verification by explicit request"); - return Ok(()); - } - - let api_key = opt_or_env(opts.ita_api_key.as_deref(), "DD_ITA_API_KEY") - .ok_or_else(|| anyhow!("DD_ITA_API_KEY required for quote verification"))?; - let base_url = opt_or_env(opts.ita_base_url.as_deref(), "DD_ITA_BASE_URL") - .unwrap_or_else(|| "https://api.trustauthority.intel.com".into()); - let jwks_url = opt_or_env(opts.ita_jwks_url.as_deref(), "DD_ITA_JWKS_URL") - .unwrap_or_else(|| "https://portal.trustauthority.intel.com/certs".into()); - let issuer = opt_or_env(opts.ita_issuer.as_deref(), "DD_ITA_ISSUER") - .unwrap_or_else(|| "https://portal.trustauthority.intel.com".into()); - - let token = crate::ita::mint(&base_url, &api_key, quote_b64) - .await - .map_err(|e| anyhow!("ITA quote appraisal failed: {e}"))?; - let verifier = crate::ita::Verifier::new(jwks_url, issuer); - let claims = verifier - .verify(&token) - .await - .map_err(|e| anyhow!("ITA token verification failed: {e}"))?; - let report_data = claims - .report_data - .as_deref() - .ok_or_else(|| anyhow!("ITA token missing attester_held_data/report_data"))?; - verify_report_data(report_data, pubkey) -} - -fn opt_or_env(opt: Option<&str>, env: &str) -> Option { - opt.map(str::to_owned).or_else(|| { - std::env::var(env) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - }) -} - -fn verify_report_data(report_data: &str, pubkey: &[u8; 32]) -> anyhow::Result<()> { - let bytes = decode_report_data(report_data)?; - match bytes.len() { - 32 if bytes.as_slice() == pubkey => Ok(()), - 64 if bytes[..32] == pubkey[..] && bytes[32..].iter().all(|b| *b == 0) => Ok(()), - 32 | 64 => anyhow::bail!("TDX report_data does not bind expected Noise public key"), - n => anyhow::bail!("TDX report_data decoded to {n} bytes, expected 32 or 64"), - } -} - -fn decode_report_data(report_data: &str) -> anyhow::Result> { - let s = report_data.trim(); - let hexish = s.strip_prefix("0x").unwrap_or(s); - if hexish.len().is_multiple_of(2) && hexish.bytes().all(|b| b.is_ascii_hexdigit()) { - return hex::decode(hexish).context("decode ITA report_data hex"); - } - for engine in [ - &base64::engine::general_purpose::STANDARD, - &base64::engine::general_purpose::STANDARD_NO_PAD, - &base64::engine::general_purpose::URL_SAFE, - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - ] { - if let Ok(bytes) = engine.decode(s) { - return Ok(bytes); - } - } - anyhow::bail!("ITA report_data is neither hex nor base64") -} - -async fn create_session(conn: &mut NoiseConnection, opts: &Opts) -> anyhow::Result { - let mut request = serde_json::Map::from_iter([( - "method".to_string(), - Value::String("shell.create_session".into()), - )]); - if let Some(recipe) = opts.recipe.as_deref() { - request.insert("recipe_id".into(), Value::String(recipe.into())); - } - if let Some(name) = opts.name.as_deref() { - request.insert("name".into(), Value::String(name.into())); - } - if let Some(command) = opts.command.as_deref() { - request.insert("command".into(), Value::String(command.into())); - } - conn.call(Value::Object(request)).await -} - -async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { - let ack = conn - .call(serde_json::json!({ - "method": "shell.attach_session", - "id": id, - "tail": true, - })) - .await?; - if ack.get("error").is_some() { - anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); - } - - let _raw = RawMode::enter()?; - let mut stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut in_buf = [0u8; ATTACH_CHUNK]; - - loop { - tokio::select! { - n = stdin.read(&mut in_buf) => { - let n = n?; - if n == 0 { - break; - } - send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; - } - frame = next_binary(&mut conn.stream) => { - let Some(cipher) = frame? else { - break; - }; - let mut plain = vec![0u8; cipher.len()]; - let n = conn.transport.read_message(&cipher, &mut plain)?; - stdout.write_all(&plain[..n]).await?; - stdout.flush().await?; - } - } - } - Ok(()) -} - -async fn send_encrypted( - transport: &mut TransportState, - sink: &mut WsSink, - plain: &[u8], -) -> anyhow::Result<()> { - let mut cipher = vec![0u8; plain.len() + 16]; - let n = transport.write_message(plain, &mut cipher)?; - cipher.truncate(n); - sink.send(WsMessage::Binary(cipher.into())).await?; - Ok(()) -} - -async fn next_binary(stream: &mut WsRead) -> anyhow::Result>> { - while let Some(msg) = stream.next().await { - match msg? { - WsMessage::Binary(b) => return Ok(Some(b.to_vec())), - WsMessage::Close(_) => return Ok(None), - WsMessage::Text(_) | WsMessage::Ping(_) | WsMessage::Pong(_) | WsMessage::Frame(_) => { - continue - } - } - } - Ok(None) -} - -fn session_id(value: &Value) -> anyhow::Result { - if let Some(error) = value.get("error") { - anyhow::bail!("create failed: {error}"); - } - value - .get("id") - .or_else(|| value.pointer("/session/id")) - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("create response did not include a session id: {value}")) -} - -async fn load_or_create_key(path: &Path) -> anyhow::Result { - match tokio::fs::read(path).await { - Ok(bytes) if bytes.len() == 32 => { - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(StaticSecret::from(key)) - } - Ok(bytes) => anyhow::bail!("{} is {} bytes, expected 32", path.display(), bytes.len()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let secret = StaticSecret::random_from_rng(OsRng); - persist_key(path, secret.as_bytes()).await?; - Ok(secret) - } - Err(e) => Err(e).with_context(|| format!("read {}", path.display())), - } -} - -async fn persist_key(path: &Path, bytes: &[u8; 32]) -> anyhow::Result<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let tmp = path.with_extension("key.tmp"); - tokio::fs::write(&tmp, bytes).await?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).await?; - } - tokio::fs::rename(&tmp, path).await?; - Ok(()) -} - -fn public_hex(secret: &StaticSecret) -> String { - hex::encode(PublicKey::from(secret).as_bytes()) -} - -fn default_key_path() -> PathBuf { - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home) - .join(".config") - .join("devopsdefender") - .join("noise.key"); - } - PathBuf::from(".devopsdefender-noise.key") -} - -fn default_label() -> String { - std::env::var("HOSTNAME") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "native-cli".into()) -} - -fn enrollment_url(cp_url: &str, pubkey_hex: &str, label: &str) -> String { - format!( - "{}/admin/enroll?pubkey={}&label={}", - normalize_http_base(cp_url), - pubkey_hex, - urlencoding::encode(label) - ) -} - -fn health_url(base_url: &str) -> String { - format!("{}/health", normalize_http_base(base_url)) -} - -fn noise_ws_url(base_url: &str) -> String { - let base = normalize_http_base(base_url); - let ws_base = if let Some(rest) = base.strip_prefix("https://") { - format!("wss://{rest}") - } else if let Some(rest) = base.strip_prefix("http://") { - format!("ws://{rest}") - } else { - base - }; - format!("{}/noise/ws", ws_base.trim_end_matches('/')) -} - -fn normalize_http_base(base_url: &str) -> String { - let trimmed = base_url.trim().trim_end_matches('/'); - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - trimmed.to_string() - } else { - format!("https://{trimmed}") - } -} - -fn print_json(value: Value) -> anyhow::Result<()> { - println!("{}", serde_json::to_string_pretty(&value)?); - Ok(()) -} - -struct RawMode { - #[cfg(unix)] - original: Option, -} - -impl RawMode { - fn enter() -> anyhow::Result { - #[cfg(unix)] - { - if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { - return Ok(Self { original: None }); - } - let mut original = std::mem::MaybeUninit::::uninit(); - if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcgetattr"); - } - let original = unsafe { original.assume_init() }; - let mut raw = original; - unsafe { libc::cfmakeraw(&mut raw) }; - if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcsetattr raw"); - } - Ok(Self { - original: Some(original), - }) - } - #[cfg(not(unix))] - { - Ok(Self {}) - } - } -} - -impl Drop for RawMode { - fn drop(&mut self) { - #[cfg(unix)] - if let Some(original) = &self.original { - let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_urls_from_bare_host() { - assert_eq!( - health_url("agent.example.com/"), - "https://agent.example.com/health" - ); - assert_eq!( - noise_ws_url("agent.example.com/"), - "wss://agent.example.com/noise/ws" - ); - } - - #[test] - fn keeps_local_http_scheme() { - assert_eq!( - health_url("http://127.0.0.1:8080"), - "http://127.0.0.1:8080/health" - ); - assert_eq!( - noise_ws_url("http://127.0.0.1:8080"), - "ws://127.0.0.1:8080/noise/ws" - ); - } - - #[test] - fn enrollment_url_encodes_label() { - assert_eq!( - enrollment_url("https://cp.example.com/", "abcd", "me laptop"), - "https://cp.example.com/admin/enroll?pubkey=abcd&label=me%20laptop" - ); - } - - #[test] - fn report_data_accepts_64_byte_hex_binding() { - let mut report = [0u8; 64]; - report[..32].fill(7); - let pubkey = [7u8; 32]; - verify_report_data(&hex::encode(report), &pubkey).unwrap(); - } - - #[test] - fn report_data_rejects_wrong_key() { - let mut report = [0u8; 64]; - report[..32].fill(7); - let pubkey = [8u8; 32]; - assert!(verify_report_data(&hex::encode(report), &pubkey).is_err()); - } - - #[test] - fn report_data_accepts_base64_binding() { - let mut report = [0u8; 64]; - report[..32].fill(9); - let pubkey = [9u8; 32]; - let encoded = base64::engine::general_purpose::STANDARD.encode(report); - verify_report_data(&encoded, &pubkey).unwrap(); - } -} From 542b3e9a2bbfaf56adb3cb4385f0e1b5685cdc93 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 15:12:55 +0000 Subject: [PATCH 11/13] Link dd client repo in docs --- README.md | 5 +++-- docs/sessiond-central-ui-plan.md | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 23c6230..03e75d1 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,9 @@ inside the Noise transport to the agent and then to local `dd-sessiond`; the CP is used for enrollment brokering and route discovery, not for shell/log/session bytes or paired-device trust storage. -Client implementations live outside this repo, starting with the planned -`dd-client` repo that will contain the shared client core, CLI, and native app. +Client implementations live outside this repo in +[`devopsdefender/dd-client`](https://github.com/devopsdefender/dd-client), +which contains the shared client core, CLI, and native app workspace. The browser remains dashboard and enrollment UI only; the existing cookie-auth browser shell is transitional compatibility while the native app takes over shell/session workflows. diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md index 3395b0e..4b7d10f 100644 --- a/docs/sessiond-central-ui-plan.md +++ b/docs/sessiond-central-ui-plan.md @@ -46,8 +46,8 @@ Client responsibilities: - No CP relay or mailbox relay for shell/log/session bytes. - No browser/PWA shell client. Browser stays dashboard/enrollment only; the session client is native app/CLI. -- No bundled client CLI in `dd`. Client core, CLI, and native app live in a - separate `dd-client` repo. +- No bundled client CLI in `dd`. Client core, CLI, and native app live in + [`devopsdefender/dd-client`](https://github.com/devopsdefender/dd-client). ## Phase 1: One Disruptive Dogfood Upgrade @@ -136,8 +136,8 @@ Start simple: - CP brokers enrollment by redirecting to the selected agent and exposes current routes. - Agents hold the trusted device set they enforce for direct Noise sessions. -- Native CLI/desktop/mobile clients from `dd-client` use direct `/noise/ws` channels for - session RPCs. +- Native CLI/desktop/mobile clients from `dd-client` use direct `/noise/ws` + channels for session RPCs. Then strengthen: @@ -182,10 +182,10 @@ direct Noise, remove the old shell stack in this order: 3. Fix pairing durability without making CP a shell/session state owner. CP can broker enrollment, but durable paired-device trust must live with the enforcement point or an explicitly chosen non-CP store. -4. Create `dd-client` with shared client core, CLI, and native app. Store - paired device keys in OS secure storage, use CP only for enrollment and - route discovery, then connect to the selected agent `/noise/ws` for session - RPCs and PTY bytes. +4. Build out [`devopsdefender/dd-client`](https://github.com/devopsdefender/dd-client) + with shared client core, CLI, and native app. Store paired device keys in OS + secure storage, use CP only for enrollment and route discovery, then connect + to the selected agent `/noise/ws` for session RPCs and PTY bytes. 5. Delete server-side browser shell proxying. Remove `src/shell.rs` session proxy routes and WebSocket attach path once the native app covers create/attach/replay/resize/close. From bf3c19d36b299e48ba20056471502c4a64fcacf1 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sun, 10 May 2026 15:34:33 +0000 Subject: [PATCH 12/13] Slim browser shell to session attach --- src/shell.rs | 994 +++++---------------------------------------------- 1 file changed, 95 insertions(+), 899 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index 4f46a18..9e33b22 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1,10 +1,11 @@ //! Multi-session shell sidecar. //! -//! One process per VM, multiple reconnectable PTY sessions, read-only workload -//! terminals, and encrypted append-only transcripts on disk. +//! Transitional browser shell proxy. +//! +//! Native clients in `devopsdefender/dd-client` are the primary shell/session +//! workflow. This sidecar keeps only the minimal browser attach surface while +//! forwarding session state and PTY bytes to local `dd-sessiond`. -use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; @@ -13,27 +14,20 @@ use axum::http::{HeaderMap, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; -use base64::Engine as _; use futures_util::{SinkExt, StreamExt}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use crate::ee::Ee; use crate::error::{Error, Result}; use crate::html; -use crate::oracle::OracleStatus; -use crate::taint::IntegrityState; -use crate::units::{self, AgentMode, ManagedUnit, UnitKind}; const DEFAULT_PORT: u16 = 7681; #[derive(Clone)] struct App { - ee: Arc, http: reqwest::Client, - agent_api: String, sessiond_http_url: String, sessiond_attach_addr: String, owner: crate::gh_oidc::Principal, @@ -55,26 +49,6 @@ struct ResizeSession { rows: u16, } -#[derive(Serialize)] -struct ReplayResponse { - id: String, - bytes_b64: String, -} - -#[derive(Serialize)] -struct SystemProbe { - ok: bool, - status: String, - detail: Option, - data: Option, -} - -#[derive(Serialize)] -struct SystemStatus { - ee: SystemProbe, - agent: SystemProbe, -} - pub async fn run() -> Result<()> { let common = crate::config::Common::from_env()?; let domain = std::env::var("DD_CF_DOMAIN") @@ -88,23 +62,17 @@ pub async fn run() -> Result<()> { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_PORT); - let ee_socket = std::env::var("DD_SHELL_EE_SOCKET") - .unwrap_or_else(|_| "/var/lib/easyenclave/agent.sock".into()); - let agent_api = std::env::var("DD_SHELL_AGENT_API_URL") - .unwrap_or_else(|_| format!("http://127.0.0.1:{}", crate::cf::AGENT_API_PORT)); let sessiond_http_url = std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into()); let sessiond_attach_addr = std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into()); let app_state = App { - ee: Arc::new(Ee::new(ee_socket)), http: reqwest::Client::builder() .timeout(Duration::from_secs(3)) .no_hickory_dns() .build() .unwrap_or_else(|_| crate::system_http_client()), - agent_api, sessiond_http_url, sessiond_attach_addr, owner: common.owner, @@ -123,10 +91,6 @@ pub async fn run() -> Result<()> { .route("/api/sessions/{id}/replay", get(replay_session)) .route("/api/sessions/{id}/resize", post(resize_session)) .route("/api/sessions/{id}/close", post(close_session)) - .route("/api/system", get(system_status)) - .route("/api/oracles", get(list_oracles)) - .route("/api/workloads", get(list_workloads)) - .route("/api/workloads/{app}/replay", get(replay_workload)) .route("/ws/sessions/{id}", get(attach_session)) .with_state(app_state); @@ -243,192 +207,6 @@ async fn replay_session( .map(Json) } -async fn list_workloads( - State(app): State, - headers: HeaderMap, - uri: Uri, -) -> Result>> { - ensure_shell_auth(&app, &headers, &uri)?; - if let Ok(units) = agent_get(&app, "/api/units").await { - return Ok(Json(units)); - } - - let oracles = load_oracles(&app).await; - let mut oracle_by_app: HashMap = oracles - .into_iter() - .map(|oracle| (oracle.app_name.clone(), oracle)) - .collect(); - let mut workloads = Vec::new(); - - match app.ee.list().await { - Ok(list) => { - if let Some(deployments) = list["deployments"].as_array() { - for d in deployments { - let Some(app_name) = d["app_name"].as_str() else { - continue; - }; - let id = d["id"].as_str().unwrap_or(app_name).to_string(); - let oracle = oracle_by_app.remove(app_name); - let kind = units::kind_for_app(app_name); - let mut capabilities = units::base_capabilities(kind); - capabilities.push("logs".into()); - if oracle.is_some() { - capabilities.push("oracle".into()); - } - workloads.push(ManagedUnit { - id: id.clone(), - app_name: app_name.to_string(), - title: units::title_for_app(app_name), - kind, - agent_mode: AgentMode::ReadWrite, - agent_integrity_state: IntegrityState::Controlled, - status: d["status"].as_str().unwrap_or("unknown").to_string(), - image: non_empty_string(&d["image"]), - started_at: non_empty_string(&d["started_at"]), - error_message: non_empty_string(&d["error_message"]), - source: units::source_for_app(app_name), - log_line_count: workload_log_line_count(&app.ee, &id).await, - capabilities, - refs: fallback_refs(app_name, kind, oracle.as_ref()), - oracle, - }); - } - } - } - Err(e) => { - eprintln!("dd-shell: ee workload probe unavailable: {e}"); - } - } - - workloads.extend(oracle_by_app.into_values().map(|oracle| ManagedUnit { - id: oracle.app_name.clone(), - app_name: oracle.app_name.clone(), - title: oracle.title.clone(), - kind: UnitKind::Workload, - agent_mode: AgentMode::ReadWrite, - agent_integrity_state: IntegrityState::Controlled, - status: oracle.status.clone(), - image: None, - started_at: None, - error_message: oracle.last_error.clone(), - source: units::source_for_app(&oracle.app_name), - log_line_count: 0, - capabilities: vec!["oracle".into()], - refs: fallback_refs(&oracle.app_name, UnitKind::Workload, Some(&oracle)), - oracle: Some(oracle), - })); - workloads.sort_by(|a, b| a.app_name.cmp(&b.app_name)); - Ok(Json(workloads)) -} - -async fn workload_log_line_count(ee: &Ee, id: &str) -> usize { - match ee.logs(id).await { - Ok(logs) => logs["lines"].as_array().map(|a| a.len()).unwrap_or(0), - Err(e) => { - eprintln!("dd-shell: workload log probe unavailable for {id}: {e}"); - 0 - } - } -} - -fn fallback_refs( - app_name: &str, - _kind: UnitKind, - oracle: Option<&OracleStatus>, -) -> Vec { - let mut refs = units::source_for_app(app_name) - .map(|source| vec![units::ref_item("source", "source", source)]) - .unwrap_or_default(); - if let Some(oracle) = oracle { - if let Some(url) = &oracle.vanity_url { - refs.push(units::ref_item("url", "oracle", url.clone())); - } - refs.push(units::ref_item( - "url", - "oracle-local", - oracle.local_url.clone(), - )); - } - refs -} - -fn non_empty_string(value: &serde_json::Value) -> Option { - value - .as_str() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(String::from) -} - -async fn list_oracles( - State(app): State, - headers: HeaderMap, - uri: Uri, -) -> Result>> { - ensure_shell_auth(&app, &headers, &uri)?; - Ok(Json(load_oracles(&app).await)) -} - -async fn load_oracles(app: &App) -> Vec { - match agent_get(app, "/api/oracles").await { - Ok(oracles) => oracles, - Err(e) => { - eprintln!("dd-shell: agent oracle probe unavailable: {e}"); - Vec::new() - } - } -} - -async fn system_status( - State(app): State, - headers: HeaderMap, - uri: Uri, -) -> Result> { - ensure_shell_auth(&app, &headers, &uri)?; - let ee = match app.ee.health().await { - Ok(data) => SystemProbe { - ok: true, - status: "healthy".into(), - detail: None, - data: Some(data), - }, - Err(e) => SystemProbe { - ok: false, - status: "unavailable".into(), - detail: Some(e.to_string()), - data: None, - }, - }; - let agent = match agent_get(&app, "/health").await { - Ok(data) => SystemProbe { - ok: true, - status: "healthy".into(), - detail: None, - data: Some(data), - }, - Err(e) => SystemProbe { - ok: false, - status: "unavailable".into(), - detail: Some(e.to_string()), - data: None, - }, - }; - Ok(Json(SystemStatus { ee, agent })) -} - -async fn agent_get(app: &App, path: &str) -> Result { - let url = format!("{}{}", app.agent_api.trim_end_matches('/'), path); - let resp = app.http.get(url).send().await?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(Error::Upstream(format!( - "agent api {path}: HTTP {status}: {body}" - ))); - } - Ok(resp.json().await?) -} - async fn sessiond_get(app: &App, path: &str) -> Result { let url = format!("{}{}", app.sessiond_http_url.trim_end_matches('/'), path); let resp = app.http.get(url).send().await?; @@ -486,31 +264,6 @@ async fn decode_sessiond_empty(path: &str, resp: reqwest::Response) -> Result, - headers: HeaderMap, - uri: Uri, - AxPath(name): AxPath, -) -> Result> { - ensure_shell_auth(&app, &headers, &uri)?; - let list = app.ee.list().await?; - let id = list["deployments"] - .as_array() - .and_then(|a| { - a.iter() - .find(|d| d["app_name"].as_str() == Some(name.as_str())) - }) - .and_then(|d| d["id"].as_str()) - .map(String::from) - .ok_or(Error::NotFound)?; - let logs = app.ee.logs(&id).await?; - let bytes = workload_log_bytes(&logs); - Ok(Json(ReplayResponse { - id: name, - bytes_b64: base64::engine::general_purpose::STANDARD.encode(bytes), - })) -} - async fn close_session( State(app): State, headers: HeaderMap, @@ -521,17 +274,6 @@ async fn close_session( sessiond_post_empty(&app, &format!("/api/sessions/{id}/close")).await } -fn workload_log_bytes(logs: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - if let Some(lines) = logs["lines"].as_array() { - for line in lines.iter().filter_map(|v| v.as_str()) { - out.extend_from_slice(line.as_bytes()); - out.extend_from_slice(b"\r\n"); - } - } - out -} - async fn resize_session( State(app): State, headers: HeaderMap, @@ -621,147 +363,65 @@ const SHELL_HTML: &str = r##" -