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)"