diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md new file mode 100644 index 00000000..aaee8a49 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md @@ -0,0 +1,199 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 02 +subsystem: api +tags: [session-auth, reverse-proxy, nginx, rust, security, aiui, anthropic, ollama] + +requires: + - phase: 10-key-material-hardening + provides: "data_dir/secrets/claude-api-key single-ledger pattern, is_authenticated idiom" +provides: + - "Session-gated forwarder (core/archipelago/src/api/handler/model_proxy.rs) for /aiui/api/claude/* and /aiui/api/ollama/*" + - "Both nginx server blocks re-pointed to the Rust daemon (127.0.0.1:5678) instead of the unauthenticated Python sidecar (127.0.0.1:3142)" + - "/aiui/api/openrouter/ open relay deleted from both nginx server blocks" + - "deploy-to-target.sh unconditionally tears down any pre-existing claude-api-proxy unit/key/binary on every deploy" + - "Single Claude key ledger (data_dir/secrets/claude-api-key) — the second copy (secrets/claude-api-proxy.env) is no longer written" +affects: [13-09-nginx-location-retirement, aiui-standalone-mode, node-security-posture] + +tech-stack: + added: [] + patterns: + - "HTTP path dispatch auth-gate idiom: `if !self.is_authenticated(...).await { return Ok(Self::unauthorized()) }` before any upstream/handler work, same as the existing /ws/db, /ws/remote-input, /ws/remote-relay arms" + - "Streamed (not buffered) upstream response via Body::wrap_stream(resp.bytes_stream()), matching proxy.rs's peer Range-streaming shape" + - "Header allowlisting on forward: only content-type/accept copied inbound; extra_headers supplies the outbound key/version pin — inbound authorization/x-api-key/cookie are never read" + +key-files: + created: + - core/archipelago/src/api/handler/model_proxy.rs + modified: + - core/archipelago/src/api/handler/mod.rs + - core/archipelago/src/api/rpc/system/handlers.rs + - image-recipe/configs/nginx-archipelago.conf + - scripts/deploy-to-target.sh + - scripts/setup-aiui-server.sh + +key-decisions: + - "Continuation option (a) chosen: git reset --soft HEAD~1 on the inherited WIP checkpoint (13b576da), then recommitted atomically as two per-task commits (97921d99 for Task 1, b28cc3ee for Task 2), after reading and verifying every line of the WIP diff against the plan's must_haves/acceptance criteria and the real source tree (function/type signatures cross-checked in session.rs and handler/mod.rs)." + - "Kept scripts/setup-aiui-server.sh's removal of its FileBrowser-fix step (present in the inherited WIP, not explicitly named in the plan's action text) after confirming that logic still lives, unmodified, in scripts/deploy-to-target.sh (lines ~481-500 and ~1005-1022) — nothing was lost, the script was correctly narrowed to match its own rewritten header comment." + - "Did not create tests/production-quality/aiui-proxy-closed.sh or attempt any real-node deploy/verification — that is Task 3's own deliverable and is explicitly the blocking human-verify checkpoint this plan stops at." + +requirements-completed: [AIUI-04] + +coverage: + - id: D1 + description: "Session-gated forwarder for /aiui/api/claude/* and /aiui/api/ollama/* — unauthenticated/invalid-session requests get 401 before any upstream call, missing key returns 503 (never 500, never the key path), inbound authorization/x-api-key/cookie headers are never forwarded upstream" + requirement: "AIUI-04" + verification: + - kind: unit + ref: "core/archipelago/src/api/handler/model_proxy.rs#model_proxy::tests::claude_without_session_is_401" + status: pass + - kind: unit + ref: "core/archipelago/src/api/handler/model_proxy.rs#model_proxy::tests::ollama_without_session_is_401" + status: pass + - kind: unit + ref: "core/archipelago/src/api/handler/model_proxy.rs#model_proxy::tests::claude_with_invalid_session_is_401" + status: pass + - kind: unit + ref: "core/archipelago/src/api/handler/model_proxy.rs#model_proxy::tests::missing_key_is_503_not_500" + status: pass + - kind: unit + ref: "core/archipelago/src/api/handler/model_proxy.rs#model_proxy::tests::inbound_authorization_header_is_not_forwarded" + status: pass + human_judgment: false + - id: D2 + description: "Both nginx server blocks re-pointed from the Python sidecar (127.0.0.1:3142) to the Rust daemon (127.0.0.1:5678); /aiui/api/openrouter/ deleted from both blocks" + requirement: "AIUI-04" + verification: + - kind: other + ref: "grep -c openrouter image-recipe/configs/nginx-archipelago.conf == 0; grep -c 127.0.0.1:3142 == 0; grep -c 'location /aiui/api/claude/' == 2" + status: pass + human_judgment: false + - id: D3 + description: "deploy-to-target.sh removes any pre-existing claude-api-proxy unit/key/binary unconditionally on every deploy; setup-aiui-server.sh no longer requires or patches in an ANTHROPIC_API_KEY; handlers.rs no longer writes the second key ledger or restarts the sidecar" + requirement: "AIUI-04" + verification: + - kind: other + ref: "grep -c 'PORT = 3142' scripts/deploy-to-target.sh == 0; grep -c claude-api-proxy core/archipelago/src/api/rpc/system/handlers.rs == 0; grep -c secrets/claude-api-key handlers.rs >= 1; cargo build --package archipelago exits 0" + status: pass + human_judgment: false + - id: D4 + description: "S-15 real-node deployed-surface proof (tests/production-quality/aiui-proxy-closed.sh) and the positive-path check that a logged-in operator's AIUI build still works" + verification: [] + human_judgment: true + rationale: "This is Task 3, a checkpoint:human-verify task with gate=\"blocking\" in the plan. Per plan-specific instructions this executor must not self-approve or attempt it — it requires deploying to a real node and a human confirming curl/systemctl/ss output and a live browser chat reply. Not started." + +# Metrics +duration: ~2h10m (dominated by two full-workspace cargo builds/compiles on a heavily contended shared host) +completed: 2026-08-03 +status: complete +--- + +# Phase 13 Plan 02: Close the AIUI unauthenticated model-proxy exposure (Tasks 1-2 of 3; Task 3 is a blocking checkpoint) + +**Session-gated Rust-daemon forwarder (`model_proxy.rs`, 5/5 unit tests confirmed passing) replaces the unauthenticated `claude-api-proxy.py` sidecar and the OpenRouter open relay; both nginx server blocks re-pointed, second key ledger deleted, deploy path tears down any already-provisioned sidecar.** + +## Performance + +- **Duration:** ~2h10m wall clock (session inherited a broken-pipe WIP checkpoint; most of the time was two sequential `CARGO_INCREMENTAL=0` full builds on a shared box already running bitcoind/electrumx/lnd plus a concurrent sibling-plan build and an unrelated main-checkout build — `cargo build --package archipelago` alone took 22m03s) +- **Tasks:** 2 of 3 complete (Task 3 is a blocking human-verify checkpoint this plan is required to stop at) +- **Files modified:** 6 (1 created, 5 modified) + +## Accomplishments + +- `core/archipelago/src/api/handler/model_proxy.rs` created: re-derives session auth from the request's own cookie (does not trust nginx), forwards authenticated Claude calls to `https://api.anthropic.com/` using the node's single key ledger and authenticated Ollama calls to `http://127.0.0.1:11434/`, streams responses instead of buffering, and never forwards inbound `authorization`/`x-api-key`/`cookie` headers upstream. +- Both nginx server blocks (`image-recipe/configs/nginx-archipelago.conf`, ~line 49 and ~line 961) re-pointed from the unauthenticated Python sidecar (port 3142) to the Rust daemon (127.0.0.1:5678), and the `/aiui/api/openrouter/` open relay deleted from both. +- `scripts/deploy-to-target.sh` no longer installs the `claude-api-proxy.py` sidecar; it now unconditionally stops/disables/removes any pre-existing unit, binary, and env file on every deploy, so already-provisioned nodes actually lose the old unauthenticated listener. +- `scripts/setup-aiui-server.sh` no longer requires or patches in an `ANTHROPIC_API_KEY`; its job is now just the AIUI dist rsync, matching its rewritten header comment. +- `core/archipelago/src/api/rpc/system/handlers.rs`'s `claude_api_key` setting branch no longer writes a second key copy or restarts the sidecar — `secrets/claude-api-key` (0600) is now the one and only ledger. + +## Task Commits + +Each task was committed atomically after resetting the inherited WIP checkpoint: + +1. **Task 1: Session-gated model forwarder in the Rust daemon** - `97921d99` (feat) +2. **Task 2: Retire the Python sidecar, its key, and the OpenRouter open relay** - `b28cc3ee` (fix) + +Task 3 (`checkpoint:human-verify`, `gate="blocking"`) is NOT executed — this plan halts there per its own instructions; see "Checkpoint" below. + +## Files Created/Modified + +- `core/archipelago/src/api/handler/model_proxy.rs` (new, 400 lines) - session-gated forwarder + its own `#[cfg(test)]` suite +- `core/archipelago/src/api/handler/mod.rs` - `mod model_proxy;` + one new path-dispatch arm gating `/aiui/api/claude/` and `/aiui/api/ollama/` +- `core/archipelago/src/api/rpc/system/handlers.rs` - `claude_api_key` branch no longer writes the second ledger / restarts the sidecar +- `image-recipe/configs/nginx-archipelago.conf` - both server blocks re-pointed to 127.0.0.1:5678; `openrouter` locations deleted; comments rewritten +- `scripts/deploy-to-target.sh` - `claude-api-proxy.py` heredoc/unit/env deleted; unconditional teardown step added; `3141`→`3142` sed fixups removed +- `scripts/setup-aiui-server.sh` - `ANTHROPIC_API_KEY` requirement and `patch-nginx-claude.py` step removed; narrowed to the AIUI dist rsync + +## Decisions Made + +- **Continuation option chosen: (a).** The inherited WIP commit (`13b576da`) was read in full — every diff hunk, not just a stat summary — and cross-checked against the real source tree before trusting any of it: function/type signatures in `session.rs` (`SessionStore::new_for_tests`, `.validate()`, `.create()`, `session::extract_session_cookie`), the `ApiHandler` struct's actual fields (`session_store`, `config.data_dir`), the enclosing `handle_request`'s `Result>` return type and its `match (method, path.as_str())` binding shape, and every grep-based acceptance criterion in the plan. It held up: `git reset --soft HEAD~1` followed by two focused per-task commits. +- `scripts/setup-aiui-server.sh`'s removal of the FileBrowser-fix step (present in the WIP diff, not literally named in the plan's action prose) was kept after confirming the same FileBrowser recreate/create logic already exists, unmodified, in `scripts/deploy-to-target.sh` (~line 481 and ~line 1005) — no functionality was lost, the script now matches its own rewritten header ("What it does: Rsyncs ... a locally built AIUI dist/ ... What it no longer does: ..."). +- Task 3's script (`tests/production-quality/aiui-proxy-closed.sh`) was deliberately **not** created and no real-node deploy was attempted — the plan's own instructions and the orchestrator's task brief are explicit that this executor stops before Task 3 and returns a structured checkpoint rather than self-approving a `gate="blocking"` human-verify task. + +## Deviations from Plan + +None beyond the FileBrowser-step removal noted above under Decisions, which is a faithful reading of the plan's own "the script's remaining job is the AIUI dist rsync" sentence rather than an unrequested change — flagged here for visibility rather than because it looks wrong. + +## Issues Encountered + +- **Host resource contention badly delayed (but did not prevent) test verification.** This session inherited a broken-pipe WIP checkpoint on a shared box that was simultaneously running a live Archipelago node (bitcoind, electrumx, lnd), a concurrent unrelated build in the main checkout (a different agent's federation/trust-password work), and — for part of this session — a concurrent sibling-plan build in worktree `p13-01`. `CARGO_INCREMENTAL=0 cargo build --package archipelago` completed successfully in 22m03s with zero errors (3 pre-existing unrelated warnings only). The subsequent `cargo test --package archipelago model_proxy::` compile (a single large `rustc --test` link step) ran for over an hour under system load average 35-55 and 14-15GB/23GB swap in use, and the harness eventually reported the background task as "killed". However, the 380MB test binary it produced (`target/debug/deps/archipelago-75b844ec6baa778d`, timestamped after both task commits, confirmed via `git diff 13b576da HEAD` to be byte-identical source content to what's committed) had actually finished linking. Running it directly — `./target/debug/deps/archipelago-75b844ec6baa778d model_proxy::` — **confirmed all 5 tests pass**: `claude_without_session_is_401`, `claude_with_invalid_session_is_401`, `missing_key_is_503_not_500`, `ollama_without_session_is_401`, `inbound_authorization_header_is_not_forwarded` (0.50s, 5 passed, 0 failed). A subsequent fresh `cargo test` invocation still timed out on its own bookkeeping/fingerprint check under the same host load, which is why the compiled-binary route was used as the verification path. + +## User Setup Required + +None - no external service configuration required by Tasks 1-2. Task 3, when resumed, requires deploying to a real dev node per `CLAUDE.md`'s "deploy to the dev pair BEFORE any OTA" rule and manually verifying the positive path (a logged-in operator's embedded AIUI chat still works). + +## Next Phase Readiness + +- Tasks 1 and 2 are committed, confirmed to compile (`cargo build --package archipelago`, 22m03s, 0 errors), and confirmed by direct execution of the compiled test binary to pass all 5 `model_proxy::` unit tests. The exposure this plan targets (unauthenticated `/aiui/api/claude/` and `/aiui/api/ollama/`, plus the OpenRouter open relay) is closed in the source of truth (both nginx server blocks) and on the deploy path (any existing sidecar is torn down on every deploy). +- **Blocked at Task 3** — a `checkpoint:human-verify` with `gate="blocking"`. Per plan instructions, this executor does not self-approve it. See "Checkpoint" below for what the orchestrator/human must do to resume. +- **Outstanding before this plan can be marked fully done:** execute Task 3 — write `tests/production-quality/aiui-proxy-closed.sh`, deploy to a dev node, run it, and have a human confirm the four status codes plus the positive-path browser check. +- 13-09 (nginx location-block retirement, once 13-01's `assistant.chat` path is what AIUI actually uses) depends on this plan's re-pointing being in place — it is. + +## Checkpoint + +**Type:** human-verify +**Gate:** blocking +**Plan:** 13-02 +**Progress:** 2/3 tasks complete + +### Completed Tasks + +| Task | Name | Commit | Files | +| ---- | ---- | ------ | ----- | +| 1 | Session-gated model forwarder in the Rust daemon | `97921d99` | `core/archipelago/src/api/handler/model_proxy.rs` (new), `core/archipelago/src/api/handler/mod.rs` | +| 2 | Retire the Python sidecar, its key, and the OpenRouter open relay | `b28cc3ee` | `image-recipe/configs/nginx-archipelago.conf`, `scripts/deploy-to-target.sh`, `scripts/setup-aiui-server.sh`, `core/archipelago/src/api/rpc/system/handlers.rs` | + +### Current Task + +**Task 3:** Prove it on a real node — a green cargo test proves nothing here +**Status:** blocked (not started — this executor is required to stop here) +**Blocked by:** `gate="blocking"` human-verify checkpoint; requires writing `tests/production-quality/aiui-proxy-closed.sh`, deploying to a real dev node, and a human confirming live output. (`cargo test --package archipelago model_proxy::` is already confirmed passing — see Issues Encountered — so this is not a prerequisite before deploying.) + +### Checkpoint Details + +**What would be built:** `tests/production-quality/aiui-proxy-closed.sh ` — a shell check that, with no session cookie, requests `/aiui/api/claude/v1/messages`, `/aiui/api/ollama/api/tags` and `/aiui/api/openrouter/` against a live node and asserts each returns 401/403/404 and never 200, plus SSH-based assertions that no `claude-api-proxy` unit is loaded and nothing listens on port 3142. + +**How to verify (per plan):** +1. `cd core && cargo test --package archipelago model_proxy::` — already confirmed passing (5/5) in this session via direct binary execution; re-run if the source changes. +2. Build and deploy to the dev pair per `CLAUDE.md` (archi-dev-box first). +3. Write and run `bash tests/production-quality/aiui-proxy-closed.sh `. +4. Confirm the positive case: log in to neode-ui on that node, open Chat, confirm embedded AIUI still answers. +5. On the node: `systemctl status claude-api-proxy` reports "could not be found"; `ss -ltnp | grep 3142` returns nothing. +6. Confirm the key ledger: `sudo ls /var/lib/archipelago/secrets/` shows `claude-api-key` and no `claude-api-proxy.env`. + +### Awaiting + +Orchestrator/human to either (a) provide access to a real dev node for Task 3, or (b) spawn a continuation agent with the resume signal once that access/verification is available. Type "approved" with the four observed status codes, or describe what still answered 200. + +--- +*Phase: 13-aiui-functional-conversational-node-control-and-content-surf* +*Completed: 2026-08-03 (partial — Tasks 1-2 of 3)* + +## Self-Check: PASSED + +- FOUND: `core/archipelago/src/api/handler/model_proxy.rs` +- FOUND: `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md` +- FOUND commit `97921d99` (Task 1) +- FOUND commit `b28cc3ee` (Task 2) +- `cargo build --package archipelago` exited 0 +- Direct execution of the compiled test binary confirmed 5/5 `model_proxy::` tests pass diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index fef490a8..1c7c5177 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -1,6 +1,7 @@ mod blob; mod content; mod dwn; +mod model_proxy; mod node_message; mod proxy; mod remote_input; @@ -433,6 +434,17 @@ impl ApiHandler { // RPC — auth is handled inside rpc handler per-method (Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await, + // AIUI model proxy — session-gated forwarder to Claude/Ollama, + // replacing the unauthenticated claude-api-proxy.py sidecar and + // the /aiui/api/openrouter/ open relay (13-02-PLAN.md, + // T-13-08/T-13-09/T-13-10/T-13-11). The daemon re-derives auth + // from the cookie inside handle_model_proxy — it does not trust + // nginx to have gated the request already, the same "don't trust + // the front door" discipline as /lnd-connect-info below. + (_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") => { + self.handle_model_proxy(req_with_bytes, p).await + } + // Health — unauthenticated, returns JSON with service status (Method::GET, "/health") => { let recovery_complete = crate::crash_recovery::is_recovery_complete(); diff --git a/core/archipelago/src/api/handler/model_proxy.rs b/core/archipelago/src/api/handler/model_proxy.rs new file mode 100644 index 00000000..2db43f91 --- /dev/null +++ b/core/archipelago/src/api/handler/model_proxy.rs @@ -0,0 +1,400 @@ +//! Session-gated forwarder for `/aiui/api/claude/*` and `/aiui/api/ollama/*`. +//! +//! Replaces `claude-api-proxy.py` — a standalone Python process on port 3142 +//! holding its **own** copy of the Anthropic API key, reachable with **no +//! session gate** — and retires the `/aiui/api/openrouter/` open relay +//! entirely (13-02-PLAN.md, T-13-08/T-13-09/T-13-10/T-13-11/T-13-12). Anyone +//! who could reach the node's web port could spend the owner's API budget. +//! +//! The daemon re-derives auth from the request's own session cookie — it +//! does not trust nginx to have gated the request already, the same +//! discipline `/lnd-connect-info`'s doc comment spells out for exactly this +//! reason (a second front door, or a misconfigured proxy, must not become a +//! silent bypass). It reads the node's single Claude key ledger +//! (`data_dir/secrets/claude-api-key`) fresh on every call rather than +//! caching it, and never forwards an inbound `x-api-key`, `authorization` +//! or `cookie` header upstream (T-13-14) — a caller must not be able to +//! bill a different account or leak the node's session to Anthropic. + +use super::ApiHandler; +use crate::session::{self, SessionStore}; +use anyhow::Result; +use hyper::{Body, HeaderMap, Method, Request, Response, StatusCode}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Anthropic Messages API base. The node's single key ledger +/// (`data_dir/secrets/claude-api-key`) authenticates every forwarded call. +const CLAUDE_UPSTREAM: &str = "https://api.anthropic.com/"; +/// Local Ollama. No key — the session gate exists purely to stop anonymous +/// consumption of local GPU/CPU inference (T-13-11), not to protect a secret. +const OLLAMA_UPSTREAM: &str = "http://127.0.0.1:11434/"; +/// Generous enough for a multi-turn tool-call round trip; `mesh/listener/ +/// assist.rs`'s OLLAMA_TIMEOUT (60s) is airtime-tuned for LoRa and not +/// reusable here — this path has no such constraint (13-AI-SPEC.md Pitfall 6). +const FORWARD_TIMEOUT_SECS: u64 = 180; + +impl ApiHandler { + /// Entry point wired into the `/aiui/api/claude/` and `/aiui/api/ollama/` + /// arms in `mod.rs`. Kept as a thin method so it can read + /// `self.session_store` / `self.config.data_dir`; the actual routing and + /// forwarding logic lives in free functions below so it is unit-testable + /// without constructing a full `ApiHandler` (RpcHandler + orchestrators + + /// blob store) in every test. + pub(super) async fn handle_model_proxy( + &self, + req: Request, + path: &str, + ) -> Result> { + route_model_proxy(&self.session_store, &self.config.data_dir, req, path).await + } +} + +/// Routing + auth gate, factored out of the `ApiHandler` method so tests can +/// exercise it with `SessionStore::new_for_tests` and a `tempfile` data_dir. +async fn route_model_proxy( + session_store: &SessionStore, + data_dir: &Path, + req: Request, + path: &str, +) -> Result> { + if !is_authenticated(session_store, req.headers()).await { + tracing::warn!("401 model proxy {} — session invalid or missing", path); + return Ok(unauthorized()); + } + if let Some(rest) = path.strip_prefix("/aiui/api/claude/") { + forward_claude(req, rest, data_dir).await + } else if let Some(rest) = path.strip_prefix("/aiui/api/ollama/") { + forward_ollama(req, rest).await + } else { + // Unreachable given the caller's prefix match in mod.rs, but never + // fall through to an unauthenticated 200 on an unrecognized path. + Ok(unauthorized()) + } +} + +/// Re-derive session auth from the request's own cookie. Deliberately not a +/// call back into `ApiHandler::is_authenticated` — keeping this small and +/// dependency-free is what makes the 401 behaviour unit-testable without +/// paying for a full `ApiHandler` in every test. +async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool { + match session::extract_session_cookie(headers) { + Some(token) => session_store.validate(&token).await, + None => false, + } +} + +fn unauthorized() -> Response { + let body = serde_json::json!({ "error": "Unauthorized" }); + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) + .unwrap_or_else(|_| Response::new(Body::from("Unauthorized"))) +} + +/// A plain-language 503 naming the missing key — never a 500, and never the +/// key's filesystem path (that would hand an authenticated-but-untrusted +/// caller a hint about the node's on-disk layout for no benefit to them). +fn key_not_configured() -> Response { + let body = serde_json::json!({ + "error": "Claude is not configured on this node yet — set an API key in Settings." + }); + Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) + .unwrap_or_else(|_| Response::new(Body::from("Claude is not configured"))) +} + +fn bad_gateway(msg: &str) -> Response { + let body = serde_json::json!({ "error": msg }); + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) + .unwrap_or_else(|_| Response::new(Body::from(msg.to_string()))) +} + +/// Forward an already-authenticated request to Anthropic's Messages API. +/// `rest` is the path remainder after `/aiui/api/claude/` has been stripped +/// by the caller (e.g. `v1/messages`). +async fn forward_claude(req: Request, rest: &str, data_dir: &Path) -> Result> { + let key_path: PathBuf = data_dir.join("secrets/claude-api-key"); + let api_key = match tokio::fs::read_to_string(&key_path).await { + Ok(k) if !k.trim().is_empty() => k.trim().to_string(), + _ => { + tracing::warn!("model proxy: claude key ledger missing, refusing forward"); + return Ok(key_not_configured()); + } + }; + forward( + req, + rest, + CLAUDE_UPSTREAM, + "api.anthropic.com", + &[ + ("x-api-key", api_key), + ("anthropic-version", "2023-06-01".to_string()), + ], + ) + .await +} + +/// Forward an already-authenticated request to the node's local Ollama. +/// `rest` is the path remainder after `/aiui/api/ollama/` has been stripped. +async fn forward_ollama(req: Request, rest: &str) -> Result> { + forward(req, rest, OLLAMA_UPSTREAM, "127.0.0.1:11434", &[]).await +} + +/// Shared forwarding core for both backends. Copies ONLY the inbound +/// `content-type`/`accept` request headers plus whatever `extra_headers` +/// the caller supplies (the Claude key + version pin) — the inbound +/// `x-api-key`, `authorization` and `cookie` headers are never read, let +/// alone forwarded (T-13-14). Streams the upstream response back rather +/// than buffering it, matching `proxy.rs`'s peer-content streaming shape, +/// so token-by-token replies still stream to the browser. +async fn forward( + req: Request, + rest: &str, + upstream_base: &str, + upstream_host_for_log: &str, + extra_headers: &[(&str, String)], +) -> Result> { + let method = req.method().clone(); + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get(hyper::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/json") + .to_string(); + let accept = parts + .headers + .get(hyper::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let payload = hyper::body::to_bytes(body) + .await + .map_err(|e| anyhow::anyhow!("read request payload: {e}"))?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS)) + .build() + .map_err(|e| anyhow::anyhow!("client build: {e}"))?; + + let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes()) + .unwrap_or(reqwest::Method::POST); + let url = format!("{}{}", upstream_base, rest); + let mut upstream_req = client + .request(reqwest_method, &url) + .header("content-type", content_type); + for (name, value) in extra_headers { + upstream_req = upstream_req.header(*name, value); + } + if let Some(accept) = accept { + upstream_req = upstream_req.header("accept", accept); + } + // GET requests to Ollama carry no payload; avoid sending an empty body + // on GET, which some servers treat differently from "no body at all". + if method != Method::GET || !payload.is_empty() { + upstream_req = upstream_req.body(payload.to_vec()); + } + + match upstream_req.send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + tracing::info!( + "model proxy: forwarded to {}, status={}", + upstream_host_for_log, + status + ); + stream_response(resp) + } + Err(e) => { + tracing::warn!( + "model proxy: upstream request to {} failed: {}", + upstream_host_for_log, + e + ); + Ok(bad_gateway("upstream request failed")) + } + } +} + +/// Stream the upstream response straight through instead of buffering it — +/// same shape as `proxy.rs`'s peer-content Range streamer — so a +/// token-by-token reply doesn't wait for the full response before the first +/// byte reaches the browser. +fn stream_response(resp: reqwest::Response) -> Result> { + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let mut builder = Response::builder().status(status); + for h in ["content-type", "content-length"] { + if let Some(v) = headers.get(h).and_then(|v| v.to_str().ok()) { + builder = builder.header(h, v); + } + } + builder + .body(Body::wrap_stream(resp.bytes_stream())) + .map_err(|e| anyhow::anyhow!("response build: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::Mutex as TokioMutex; + + /// Unique suffix for a per-test temp file path (matches the pattern + /// `session.rs`'s own tests already use — not key material, just a + /// filename component, drawn unguarded). + fn uniq() -> u64 { + rand::RngCore::next_u64(&mut rand::rngs::OsRng) + } + + async fn test_store() -> SessionStore { + let dir = std::env::temp_dir(); + let path = dir.join(format!("archy-model-proxy-test-sessions-{}.json", uniq())); + SessionStore::new_for_tests(path) + } + + fn req_with_cookie(method: &str, path: &str, cookie: Option<&str>) -> Request { + let mut builder = Request::builder().method(method).uri(path); + if let Some(c) = cookie { + builder = builder.header("cookie", format!("session={c}")); + } + builder.body(Body::empty()).unwrap() + } + + #[tokio::test] + async fn claude_without_session_is_401() { + let store = test_store().await; + let data_dir = tempfile::tempdir().unwrap(); + let req = req_with_cookie("POST", "/aiui/api/claude/v1/messages", None); + let resp = route_model_proxy( + &store, + data_dir.path(), + req, + "/aiui/api/claude/v1/messages", + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn ollama_without_session_is_401() { + let store = test_store().await; + let data_dir = tempfile::tempdir().unwrap(); + let req = req_with_cookie("GET", "/aiui/api/ollama/api/tags", None); + let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/ollama/api/tags") + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn claude_with_invalid_session_is_401() { + let store = test_store().await; + let data_dir = tempfile::tempdir().unwrap(); + let req = req_with_cookie( + "POST", + "/aiui/api/claude/v1/messages", + Some("not-a-real-token"), + ); + let resp = route_model_proxy( + &store, + data_dir.path(), + req, + "/aiui/api/claude/v1/messages", + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn missing_key_is_503_not_500() { + let store = test_store().await; + let token = store.create().await; + // Deliberately no data_dir/secrets/claude-api-key written. + let data_dir = tempfile::tempdir().unwrap(); + let req = req_with_cookie("POST", "/aiui/api/claude/v1/messages", Some(&token)); + let resp = route_model_proxy( + &store, + data_dir.path(), + req, + "/aiui/api/claude/v1/messages", + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + /// Minimal local capture server (hyper 0.14, same crate `server.rs` + /// already builds on) standing in for an upstream — records the headers + /// of the one request it receives so the test can assert what actually + /// left the node, without adding a mocking dependency. + async fn spawn_capture_server() -> (String, Arc>>) { + let captured: Arc>> = Arc::new(TokioMutex::new(None)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let captured_clone = captured.clone(); + tokio::spawn(async move { + if let Ok((stream, _)) = listener.accept().await { + let captured = captured_clone.clone(); + let service = hyper::service::service_fn(move |req: Request| { + let captured = captured.clone(); + async move { + *captured.lock().await = Some(req.headers().clone()); + Ok::<_, std::convert::Infallible>(Response::new(Body::from("{}"))) + } + }); + let _ = hyper::server::conn::Http::new() + .serve_connection(stream, service) + .await; + } + }); + (format!("http://{addr}/"), captured) + } + + #[tokio::test] + async fn inbound_authorization_header_is_not_forwarded() { + let (upstream, captured) = spawn_capture_server().await; + let req = Request::builder() + .method("POST") + .uri("/v1/messages") + .header("authorization", "Bearer caller-supplied-secret") + .header("x-api-key", "attacker-supplied-key") + .header("cookie", "session=some-session-token") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let resp = forward(req, "v1/messages", &upstream, "test-upstream", &[]) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Give the spawned capture task a moment to record the request. + for _ in 0..20 { + if captured.lock().await.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let headers = captured + .lock() + .await + .clone() + .expect("capture server did not receive a request"); + assert!(headers.get("authorization").is_none()); + assert!(headers.get("x-api-key").is_none()); + assert!(headers.get("cookie").is_none()); + // The one header we DO expect to survive the round trip. + assert_eq!( + headers.get("content-type").and_then(|v| v.to_str().ok()), + Some("application/json") + ); + } +} diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 30ed79da..72275f5b 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -1049,22 +1049,12 @@ impl RpcHandler { info!("Claude API key saved"); } - // Update the claude-api-proxy environment and restart - let env_line = format!("ANTHROPIC_API_KEY={}", value); - let env_file = self.config.data_dir.join("secrets/claude-api-proxy.env"); - tokio::fs::write(&env_file, &env_line).await.ok(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&env_file, std::fs::Permissions::from_mode(0o600)) - .ok(); - } - - // Restart the proxy to pick up the new key - let _ = tokio::process::Command::new("sudo") - .args(["systemctl", "restart", "claude-api-proxy"]) - .output() - .await; + // `secrets/claude-api-key` (above) is deliberately the ONLY + // Claude key ledger on this node (13-02-PLAN.md). A second + // copy used to be written alongside it for a standalone, + // unauthenticated sidecar process on port 3142 — that + // sidecar and its key copy are retired; the session-gated + // Rust daemon reads this one file directly. Ok(serde_json::json!({ "saved": true })) } diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 72e55eeb..37a98f97 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -46,12 +46,21 @@ server { add_header Cache-Control "public, max-age=31536000, immutable"; } - # AIUI Claude API proxy (API key managed by proxy, no session gate needed) + # AIUI Claude API proxy — re-pointed to the Rust daemon (127.0.0.1:5678), + # which enforces the session cookie itself and reads the node's single + # key ledger (data_dir/secrets/claude-api-key). The old comment here said + # "API key managed by proxy, no session gate needed" — that confuses key + # *secrecy* with spend *authorization* and is the reasoning error that + # made this an unauthenticated door into a paid API (T-13-08/T-13-09). + # Do not point this at a standalone process again. No trailing path on + # proxy_pass: nginx forwards the request URI unmodified so the daemon's + # own prefix match sees the full /aiui/api/claude/... path. location /aiui/api/claude/ { - proxy_pass http://127.0.0.1:3142/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -59,25 +68,18 @@ server { proxy_send_timeout 120s; } - # AIUI OpenRouter API proxy (API key managed by proxy, no session gate needed) - location /aiui/api/openrouter/ { - set $upstream_1 "https://openrouter.ai/api/"; - - proxy_pass $upstream_1; - proxy_http_version 1.1; - proxy_set_header Host openrouter.ai; - proxy_ssl_server_name on; - proxy_connect_timeout 120s; - proxy_read_timeout 120s; - proxy_send_timeout 120s; - } - - # AIUI Ollama (local AI) proxy — localhost:11434 + # AIUI Ollama (local AI) proxy — same daemon, same session gate as above. + # The standalone AIUI OpenRouter relay that used to live here is deleted + # outright: the node holds no key for that backend, it is not in the + # model backend chain, and an unauthenticated proxy_pass to a paid + # third-party API from the node's IP was a plain open relay (T-13-10). + # AIUI's own standalone/dev mode keeps its own proxy and is unaffected. location /aiui/api/ollama/ { - proxy_pass http://127.0.0.1:11434/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -958,11 +960,17 @@ server { try_files $uri $uri/ /aiui/index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; } + # See the HTTP server block above for the full rationale: re-pointed to + # the session-gated Rust daemon (T-13-08/T-13-09), OpenRouter relay + # deleted outright (T-13-10). Both server blocks must carry this fix — + # a change applied to only one leaves the exposure live on whichever + # block actually serves the request (T-13-15). location /aiui/api/claude/ { - proxy_pass http://127.0.0.1:3142/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -970,27 +978,17 @@ server { proxy_send_timeout 120s; } location /aiui/api/ollama/ { - proxy_pass http://127.0.0.1:11434/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; proxy_read_timeout 300s; proxy_send_timeout 120s; } - location /aiui/api/openrouter/ { - set $upstream_6 "https://openrouter.ai/api/"; - - proxy_pass $upstream_6; - proxy_http_version 1.1; - proxy_set_header Host openrouter.ai; - proxy_ssl_server_name on; - proxy_connect_timeout 120s; - proxy_read_timeout 120s; - proxy_send_timeout 120s; - } # Icons, favicon, manifest — always revalidate (no heuristic caching) location ~* ^/(favicon\.ico|manifest\.webmanifest|assets/icon/) { diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index dbecef51..f804cd28 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -396,7 +396,6 @@ deploy_secondary() { ssh $SSH_OPTS "$SEC_TARGET" ' sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf - sudo sed -i "s|proxy_pass http://127.0.0.1:3141/;|proxy_pass http://127.0.0.1:3142/;|g" /etc/nginx/sites-available/archipelago rm -f /tmp/nginx-archipelago.conf ' 2>/dev/null || true fi @@ -775,9 +774,6 @@ if [ "$LIVE" = true ]; then # Remove old port-based external app proxies config ssh $SSH_OPTS "$TARGET_HOST" 'sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf' 2>/dev/null || true - # Fix nginx Claude API proxy port (template uses 3141, proxy runs on 3142) - ssh $SSH_OPTS "$TARGET_HOST" 'sudo sed -i "s|proxy_pass http://127.0.0.1:3141/;|proxy_pass http://127.0.0.1:3142/;|g" /etc/nginx/sites-available/archipelago' 2>/dev/null || true - # Validate nginx config after all changes ssh $SSH_OPTS "$TARGET_HOST" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " ⚠️ nginx config test failed"' 2>/dev/null || true @@ -873,87 +869,28 @@ if [ "$LIVE" = true ]; then ' 2>/dev/null || true fi - # Deploy Claude API proxy (auto-install if missing) - progress "Setting up Claude API proxy" + # Retire the Claude API proxy sidecar (13-02-PLAN.md — closing a live + # production exposure). This used to install/restart a standalone Python + # process on port 3142 holding its OWN copy of ANTHROPIC_API_KEY, reachable + # with no session gate — anyone who could reach the node's web port could + # spend the owner's API budget (T-13-08/T-13-09). AIUI's Claude/Ollama + # calls now route through the Rust daemon (127.0.0.1:5678, see the nginx + # sync above), which enforces the session cookie and reads the node's + # single key ledger (data_dir/secrets/claude-api-key). + # + # This step must run unconditionally on every deploy, not just fresh + # installs: deploying the daemon fix without tearing down an + # already-provisioned node's sidecar leaves the old unauthenticated + # listener running right alongside the new authenticated one. + progress "Removing legacy Claude API proxy sidecar" ssh $SSH_OPTS "$TARGET_HOST" ' - echo " Updating Claude API proxy on port 3142..." - # Check for API key in existing service or setup-aiui-server.sh - EXISTING_KEY=$(grep -oP "ANTHROPIC_API_KEY=\K.*" /etc/systemd/system/claude-api-proxy.service 2>/dev/null || true) - if [ -z "$EXISTING_KEY" ]; then - echo " ⚠️ No ANTHROPIC_API_KEY found — run setup-aiui-server.sh first to configure" - else - # Proxy script - sudo tee /opt/archipelago/claude-api-proxy.py > /dev/null << '\''PYEOF'\'' -#!/usr/bin/env python3 -import http.server, json, ssl, sys, os, urllib.request, urllib.error -API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") -PORT = 3142 -class Handler(http.server.BaseHTTPRequestHandler): - def do_POST(self): - if self.path == "/health": - self.send_response(200); self.send_header("Content-Type","application/json"); self.end_headers() - self.wfile.write(b"{\"status\":\"ok\"}"); return - cl = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(cl) - try: data = json.loads(body) - except: data = {} - if "max_tokens" not in data: data["max_tokens"] = 8096 - for f in ["webSearch","web_search"]: data.pop(f, None) - # Normalize model IDs — map short/dotted names to full API model IDs - MODEL_MAP = { - "claude-haiku-4.5": "claude-haiku-4-5-20251001", - "claude-haiku-4-5": "claude-haiku-4-5-20251001", - "claude-sonnet-4": "claude-sonnet-4-20250514", - "claude-sonnet-4.5": "claude-sonnet-4-5-20250514", - "claude-sonnet-4-5": "claude-sonnet-4-5-20250514", - "claude-opus-4": "claude-opus-4-20250514", - } - m = data.get("model", "") - if m in MODEL_MAP: data["model"] = MODEL_MAP[m] - body = json.dumps(data).encode() - if not API_KEY: - err = json.dumps({"type":"error","error":{"type":"auth_error","message":"AIUI not configured. Set your Anthropic API key in Settings > AIUI to enable AI chat."}}).encode() - self.send_response(401); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(err))); self.end_headers(); self.wfile.write(err); return - headers = {"Content-Type":"application/json","x-api-key":API_KEY,"anthropic-version":"2023-06-01","anthropic-dangerous-direct-browser-access":"true"} - for h in ["anthropic-version","anthropic-beta"]: - if self.headers.get(h): headers[h] = self.headers[h] - req = urllib.request.Request("https://api.anthropic.com"+self.path, data=body, headers=headers, method="POST") - try: - ctx = ssl.create_default_context() - resp = urllib.request.urlopen(req, context=ctx, timeout=300) - self.send_response(resp.status) - is_stream = "text/event-stream" in (resp.headers.get("Content-Type","") or "") - for k,v in resp.headers.items(): - if k.lower() not in ("transfer-encoding","connection"): self.send_header(k,v) - if is_stream: self.send_header("Transfer-Encoding","chunked") - self.end_headers() - if is_stream: - while True: - chunk = resp.read(4096) - if not chunk: break - self.wfile.write(b"%x\r\n" % len(chunk)); self.wfile.write(chunk); self.wfile.write(b"\r\n"); self.wfile.flush() - self.wfile.write(b"0\r\n\r\n"); self.wfile.flush() - else: self.wfile.write(resp.read()) - except urllib.error.HTTPError as e: - self.send_response(e.code); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(e.read()) - except Exception as e: - self.send_response(502); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(json.dumps({"error":str(e)}).encode()) - def do_GET(self): - if self.path == "/health": - self.send_response(200); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(b"{\"status\":\"ok\"}") - else: self.send_response(404); self.end_headers() - def log_message(self, fmt, *args): pass -if not API_KEY: print("WARNING: ANTHROPIC_API_KEY not set — AIUI will return setup instructions") -server = http.server.HTTPServer(("127.0.0.1", PORT), Handler) -print(f"Claude API proxy on port {PORT}") -server.serve_forever() -PYEOF - sudo systemctl daemon-reload - sudo systemctl enable claude-api-proxy - sudo systemctl restart claude-api-proxy - sleep 1 - echo " Claude API proxy: $(systemctl is-active claude-api-proxy)" - fi + sudo systemctl stop claude-api-proxy 2>/dev/null || true + sudo systemctl disable claude-api-proxy 2>/dev/null || true + sudo rm -f /etc/systemd/system/claude-api-proxy.service + sudo rm -f /opt/archipelago/claude-api-proxy.py + sudo rm -f /var/lib/archipelago/secrets/claude-api-proxy.env + sudo systemctl daemon-reload 2>/dev/null || true + echo " claude-api-proxy: $(systemctl is-active claude-api-proxy 2>&1)" ' 2>/dev/null || true # Dev mode for Tailscale HTTP access (cookies need Secure flag disabled over plain HTTP) diff --git a/scripts/setup-aiui-server.sh b/scripts/setup-aiui-server.sh index cbf3fc4a..fda5a849 100755 --- a/scripts/setup-aiui-server.sh +++ b/scripts/setup-aiui-server.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Setup AIUI + Claude API proxy + FileBrowser on any Archipelago server +# Deploy the AIUI (Chat mode iframe) build to an Archipelago server. # # Usage: # ./scripts/setup-aiui-server.sh @@ -8,10 +8,20 @@ # ./scripts/setup-aiui-server.sh archipelago@192.168.1.228 # # What it does: -# 1. Deploys AIUI files (from local build) -# 2. Configures nginx Claude API proxy (direct to Anthropic with API key) -# 3. Fixes FileBrowser container (removes read-only root if needed) -# 4. Reloads nginx +# Rsyncs (or tar+scp, if rsync is unavailable on the target) a locally +# built AIUI dist/ into /opt/archipelago/web-ui/aiui/ on the target node. +# +# What it no longer does (13-02-PLAN.md — closing a live production +# exposure): it used to also patch nginx to route /aiui/api/claude/ to a +# standalone Python proxy holding its own ANTHROPIC_API_KEY, with no session +# gate — anyone who could reach the node's web port could spend the owner's +# API budget. That proxy, its systemd unit, and this script's nginx-patch +# step are all deleted (see scripts/deploy-to-target.sh's "Removing legacy +# Claude API proxy sidecar" step). AIUI's Claude/Ollama calls now route +# through the Rust daemon (127.0.0.1:5678), which enforces the session +# cookie itself and reads the node's single key ledger. Set the key via +# `system.settings.set claude_api_key` (Settings > AIUI in neode-ui) — this +# script has nothing to do with the key anymore. # # Prerequisites: # - AIUI must be built locally first: cd AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build @@ -24,10 +34,6 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}" SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY" -# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the -# caller's environment or scripts/deploy-config.sh; never commit live keys. -ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" - TARGET_HOST="$1" if [ -z "$TARGET_HOST" ]; then echo "Usage: $0 " @@ -35,12 +41,6 @@ if [ -z "$TARGET_HOST" ]; then exit 1 fi -if [ -z "$ANTHROPIC_API_KEY" ]; then - echo "ERROR: ANTHROPIC_API_KEY must be set in the environment." - echo "Example: ANTHROPIC_API_KEY= $0 $TARGET_HOST" - exit 1 -fi - AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist" if [ ! -f "$AIUI_DIST/index.html" ]; then echo "ERROR: AIUI build not found at $AIUI_DIST" @@ -51,15 +51,14 @@ fi timestamp() { echo "[$(date +%H:%M:%S)]"; } echo "╔════════════════════════════════════════════════════════════╗" -echo "║ Archipelago AIUI + Claude API Setup ║" +echo "║ Archipelago AIUI deploy ║" echo "║ Target: $TARGET_HOST" echo "╚════════════════════════════════════════════════════════════╝" -# --- Step 1: Deploy AIUI files --- +# --- Deploy AIUI files --- echo "" echo "$(timestamp) 📦 Deploying AIUI files..." -# Check if rsync is available on remote if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3 else @@ -72,105 +71,17 @@ else fi echo " AIUI deployed." -# --- Step 2: Configure nginx Claude API proxy --- -echo "" -echo "$(timestamp) 🔧 Configuring nginx Claude API proxy..." - -# Create a Python script to patch nginx config -cat << 'PYSCRIPT' > /tmp/patch-nginx-claude.py -import sys -import re - -API_KEY = sys.argv[1] - -with open("/etc/nginx/sites-available/archipelago") as f: - content = f.read() - -# The new Claude API proxy block -new_block = '''location /aiui/api/claude/ { - if ($cookie_session = "") { - return 401 '{"error":"Unauthorized"}'; - } - proxy_pass https://api.anthropic.com/; - proxy_http_version 1.1; - proxy_set_header Host api.anthropic.com; - proxy_set_header x-api-key "''' + API_KEY + '''"; - proxy_set_header anthropic-version "2023-06-01"; - proxy_set_header anthropic-dangerous-direct-browser-access "true"; - proxy_ssl_server_name on; - proxy_set_header X-Real-IP $remote_addr; - proxy_buffering off; - proxy_cache off; - proxy_connect_timeout 120s; - proxy_read_timeout 300s; - proxy_send_timeout 120s; - }''' - -# Replace existing Claude API proxy blocks (handles both old proxy and direct patterns) -pattern = r'location /aiui/api/claude/ \{[^}]*(?:\{[^}]*\}[^}]*)*\}' -content = re.sub(pattern, new_block, content) - -with open("/etc/nginx/sites-available/archipelago", "w") as f: - f.write(content) - -# Verify -count = content.count("api.anthropic.com") -print(f" Patched {count // 2} Claude API proxy blocks (HTTP + HTTPS)") -PYSCRIPT - -scp $SSH_OPTS /tmp/patch-nginx-claude.py "$TARGET_HOST:/tmp/patch-nginx-claude.py" -ssh $SSH_OPTS "$TARGET_HOST" "sudo python3 /tmp/patch-nginx-claude.py '$ANTHROPIC_API_KEY'" - -# Test and reload nginx -echo " Testing nginx config..." -ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t 2>&1 && sudo systemctl reload nginx && echo ' Nginx reloaded OK'" || { - echo " ERROR: nginx config test failed!" - exit 1 -} - -# --- Step 3: Fix FileBrowser container --- -echo "" -echo "$(timestamp) 📁 Checking FileBrowser..." - -FB_STATUS=$(ssh $SSH_OPTS "$TARGET_HOST" "podman inspect filebrowser 2>/dev/null | grep -oP '\"ReadonlyRootfs\":\s*\K\w+'" 2>/dev/null || echo "not_found") - -if [ "$FB_STATUS" = "true" ]; then - echo " FileBrowser has read-only root — recreating..." - ssh $SSH_OPTS "$TARGET_HOST" " - podman stop filebrowser 2>/dev/null - podman rm filebrowser 2>/dev/null - sudo mkdir -p /var/lib/archipelago/filebrowser - podman run -d --name filebrowser --restart=always \ - -p 8083:80 \ - -v /var/lib/archipelago/filebrowser:/srv \ - filebrowser/filebrowser:v2.27.0 - " 2>&1 | tail -2 - echo " FileBrowser recreated." -elif [ "$FB_STATUS" = "not_found" ]; then - echo " FileBrowser not found — creating..." - ssh $SSH_OPTS "$TARGET_HOST" " - sudo mkdir -p /var/lib/archipelago/filebrowser - podman run -d --name filebrowser --restart=always \ - -p 8083:80 \ - -v /var/lib/archipelago/filebrowser:/srv \ - filebrowser/filebrowser:v2.27.0 - " 2>&1 | tail -2 - echo " FileBrowser created." -else - echo " FileBrowser OK (ReadonlyRootfs: $FB_STATUS)" -fi - -# --- Step 4: Verify --- +# --- Verify --- echo "" echo "$(timestamp) ✅ Verification..." ssh $SSH_OPTS "$TARGET_HOST" " echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\" - echo \" FileBrowser: \$(podman ps --format '{{.Names}} {{.Status}}' | grep filebrowser)\" echo \" Nginx: \$(systemctl is-active nginx)\" echo \" Backend: \$(systemctl is-active archipelago)\" - echo \" Claude API test: \$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -H 'Cookie: session=test' -d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}')\" " echo "" -echo "$(timestamp) Done! Server configured." +echo "$(timestamp) Done! AIUI deployed." +echo " Set the Claude API key (if not already set) via Settings > AIUI in" +echo " neode-ui — it now lives only at /secrets/claude-api-key." echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"