OXYGENHUB

Radar from another machine: spectra open's loopback proxy to the pinned server (#24) 01320eefd505

A browser cannot pin the instance's certificate, so spectra open serves
Radar on loopback through a proxy that forwards over pinned TLS and
answers only its own host and origin. Checked by hand in Chrome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key cefdf3c8e865
When
· 2026-11-03 13:44 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
01320eefd5059e082d02ad4c6530f4cb51e5a6cc014b2441aa73a8a3ec4daa1a
Depends on
258bb3824813 39f5c913f617 571572f5fb2c 67bfa06a894f d17cbc7069ce da84e98f104e e071e91a14ef eb675dfa5e18
Depended on by
862f0db224aa 8d803d858c8c 58e1ce30ae58 aa72eb904932
In bookmarks
main
In tags
v0.1.0
Edits
14 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/spectra-mcp/src/proxy.rs +90 −0
1 +
//! Radar from a workstation (spec §8.6): a browser cannot pin the
2 +
//! instance's certificate, so `spectra open` serves a loopback proxy that
3 +
//! forwards to the pinned server over TLS. It answers only loopback `Host`
4 +
//! and `Origin`, so no other site can reach the server through it, and
5 +
//! passes responses (the SSE stream included) through as they come.
6 +
7 +
use crate::client::{Client, Endpoint};
8 +
use axum::body::{Body, Bytes};
9 +
use axum::extract::{Request, State};
10 +
use axum::http::{header, HeaderMap, StatusCode};
11 +
use axum::response::{IntoResponse, Response};
12 +
use http_body_util::{BodyExt, Full};
13 +
use std::sync::Arc;
14 +
15 +
struct Proxy {
16 +
    upstream: Client,
17 +
    /// `127.0.0.1:PORT` and `localhost:PORT`.
18 +
    hosts: Vec<String>,
19 +
}
20 +
21 +
/// The proxy's routes, forwarding to `upstream` as the browser at
22 +
/// `127.0.0.1:port` asks.
23 +
pub fn router(upstream: Endpoint, port: u16) -> axum::Router {
24 +
    let p = Arc::new(Proxy { upstream: Client::at(upstream, None), hosts: vec![format!("127.0.0.1:{port}"), format!("localhost:{port}")] });
25 +
    axum::Router::new().fallback(forward).with_state(p)
26 +
}
27 +
28 +
/// The sign-in link the server made, pointed at the proxy instead.
29 +
pub fn local_link(url: &str, port: u16) -> String {
30 +
    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
31 +
    let path = rest.find('/').map(|i| &rest[i..]).unwrap_or("/");
32 +
    format!("http://127.0.0.1:{port}{path}")
33 +
}
34 +
35 +
fn ours(p: &Proxy, headers: &HeaderMap) -> bool {
36 +
    let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("");
37 +
    if !p.hosts.iter().any(|h| h == host) {
38 +
        return false;
39 +
    }
40 +
    match headers.get(header::ORIGIN).and_then(|o| o.to_str().ok()) {
41 +
        None => true,
42 +
        Some(o) => p.hosts.iter().any(|h| o == format!("http://{h}")),
43 +
    }
44 +
}
45 +
46 +
async fn forward(State(p): State<Arc<Proxy>>, req: Request) -> Response {
47 +
    if !ours(&p, req.headers()) {
48 +
        return (StatusCode::FORBIDDEN, "this proxy serves its own page only").into_response();
49 +
    }
50 +
    let (parts, body) = req.into_parts();
51 +
    let body: Bytes = match body.collect().await {
52 +
        Ok(b) => b.to_bytes(),
53 +
        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
54 +
    };
55 +
    let path = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
56 +
    let mut up = hyper::Request::builder().method(parts.method.clone()).uri(path);
57 +
    for (k, v) in parts.headers.iter() {
58 +
        // The proxy has checked the origin itself; hop-by-hop headers stay here.
59 +
        if matches!(k.as_str(), "host" | "origin" | "connection" | "keep-alive" | "transfer-encoding" | "upgrade" | "referer") {
60 +
            continue;
61 +
        }
62 +
        up = up.header(k, v);
63 +
    }
64 +
    let up = match up.header(header::HOST, "spectra").header(header::CONNECTION, "close").body(Full::new(body)) {
65 +
        Ok(r) => r,
66 +
        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
67 +
    };
68 +
    match p.upstream.forward(up).await {
69 +
        Ok(resp) => {
70 +
            let (parts, incoming) = resp.into_parts();
71 +
            let mut out = Response::new(Body::new(incoming));
72 +
            *out.status_mut() = parts.status;
73 +
            for (k, v) in parts.headers.iter() {
74 +
                if !matches!(k.as_str(), "connection" | "transfer-encoding" | "keep-alive") {
75 +
                    out.headers_mut().append(k, v.clone());
76 +
                }
77 +
            }
78 +
            out
79 +
        }
80 +
        Err(e) => (StatusCode::BAD_GATEWAY, format!("Spectra on the server did not answer: {e:#}")).into_response(),
81 +
    }
82 +
}
83 +
84 +
#[cfg(test)]
85 +
mod tests {
86 +
    #[test]
87 +
    fn the_link_points_at_the_proxy() {
88 +
        assert_eq!(super::local_link("http://127.0.0.1:7350/signin?code=abc", 7351), "http://127.0.0.1:7351/signin?code=abc");
89 +
    }
90 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: Radar from another machine (#24).** With a server configured, `spectra open` serves Radar on `http://127.0.0.1:7350` (`--port` to change it) through a proxy to the pinned server, and signs you in through it; pages, forms and the live stream all pass through. The proxy answers only its own loopback host and origin, so no other site can reach the server through it. Ctrl-C stops it.
5 6
- **Changed: the command line works from another machine (#23).** With a server configured by `spectra connect`, every command goes through the JSON API and the workstation needs no ledger file: `identity show`, `agent add`, `list` and `disable`, `project add` and `list`, `say`, `inbox`, `search`, `api` and `admin verify`, which the server runs on its own ledger (`GET /api/v1/admin/verify`). `admin check` and `rebuild` say they run on the server. Locally, commands read through the daemon whenever it is serving, so the same path is exercised on one machine and on two. The API's agents now carry their delegation state and expiry, projects their agents, and inbox items and search hits their author's display name.
6 7
- **Added: `spectra connect https://HOST[:PORT]` (#21).** Uses a Spectra on another machine. It trusts the server only when your own identity delegates to its instance key and that key vouches for the certificate it presents; until you have delegated, it saves the instance's public identity and prints the `oxy identity delegate add` to run. Then it pins the certificate in `~/.config/spectra/client.toml` (or `SPECTRA_SERVER`), claims a server set up with `spectra init --owner`, and brings the server up to your current identity. A certificate that changes is refused until `connect` checks it again. The stdio shim uses the configured server (with `--home`).
7 8
- **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why.
…
16 unchanged lines
modified crates/spectra-mcp/src/client.rs +11 −0
…
146 unchanged lines
147 147
}
148 148
149 149
impl Client {
150 +
    /// Pass a request through as it is, unsigned, and hand back the
151 +
    /// response with its body still streaming (Radar's proxy, spec §8.6).
152 +
    pub async fn forward(&self, req: hyper::Request<Full<Bytes>>) -> Result<hyper::Response<hyper::body::Incoming>> {
153 +
        let stream = self.io().await?;
154 +
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
155 +
        tokio::spawn(async move {
156 +
            let _ = conn.await;
157 +
        });
158 +
        Ok(sender.send_request(req).await?)
159 +
    }
160 +
150 161
    /// Send a signed GET and hand each line of the streamed body to `line`
151 162
    /// as it arrives (an SSE stream, say); stop when it returns false.
152 163
    pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> {
…
58 unchanged lines
modified crates/spectra-mcp/src/lib.rs +1 −0
…
6 unchanged lines
7 7
pub mod http;
8 8
pub mod instance;
9 9
pub mod protocol;
10 +
pub mod proxy;
10 11
pub mod session;
11 12
pub mod tls;
12 13
pub mod tools;
…
11 unchanged lines
modified docs/plan.md +1 −1
…
33 unchanged lines
34 34
- [x] #22 Remote setup — done 2026-11-03: spectra init --owner KEYID; unowned daemon refuses all but /api/v1/instance and POST /api/v1/claim, which takes only the named key; tests/remote.rs. Found: a failing end-to-end test left its daemon running, which held the output open and looked like a hang; Serving now kills it on drop.
35 35
- [x] #21 spectra connect — done 2026-11-03: trust through the owner's delegation to the instance key, pin, claim with the rotation delegating causes; tests/connect.rs, and the shim over TLS in tests/client.rs.
36 36
- [x] #23 The CLI without a ledger file — done 2026-11-03: every command through the API when a daemon serves, local or remote; admin verify via the server; tests/client.rs runs them all from a workstation with no data directory.
37 -
- [ ] #24 Radar from a workstation: spectra open's loopback proxy to the pinned server.
37 +
- [x] #24 Radar from a workstation — done 2026-11-03: spectra open serves a loopback proxy to the pinned server and signs in through it; tests/client.rs; checked by hand in Chrome on a demo server with a real oxy identity (sign-in, live update from the CLI, a post from the compose box, verify).
38 38
- [ ] #25 Run the daemon on Earth: install over SSH, a unit that waits for a Spectra, README.
39 39
- [ ] #13 Acceptance (with Andy), across two machines. Ready when the rest is done:
40 40
  1. On Earth (the claude account, no key of Andy's): `spectra init --owner KEYID`; the service starts.
…
16 unchanged lines
modified src/cli/mod.rs +11 −1
…
94 unchanged lines
95 95
        /// Print the link without opening a browser
96 96
        #[arg(long)]
97 97
        no_browser: bool,
98 +
        /// With Spectra on another machine: the loopback port of the proxy
99 +
        /// Radar is served through (spec §8.6)
100 +
        #[arg(long, default_value_t = 7350)]
101 +
        port: u16,
98 102
    },
99 103
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
100 104
    Mcp {
…
394 unchanged lines
495 499
            init_logging();
496 500
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
497 501
        }
498 -
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
502 +
        Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async {
503 +
            if ctx.remote()?.is_some() {
504 +
                open::open_remote(&ctx, owner_home, !no_browser, port).await
505 +
            } else {
506 +
                open::open(&ctx, owner_home, !no_browser).await
507 +
            }
508 +
        }),
499 509
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
500 510
        Command::Say { body, channel, reply_to, json, owner_home } => {
501 511
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
…
47 unchanged lines
modified src/cli/open.rs +38 −2
…
22 unchanged lines
23 23
    }
24 24
    let url = r.json()?.get("url").and_then(|u| u.as_str()).map(String::from).context("no link in the answer")?;
25 25
    println!("{} {}", ui::done("Signed"), ui::subject("a one-time link to Radar, good for a minute"));
26 -
    println!("  {}", ui::id(&url));
26 +
    show(&url, launch);
27 +
    Ok(0)
28 +
}
29 +
30 +
fn show(url: &str, launch: bool) {
31 +
    println!("  {}", ui::id(url));
27 32
    if launch
28 33
        && std::process::Command::new("xdg-open")
29 -
            .arg(&url)
34 +
            .arg(url)
30 35
            .stdout(std::process::Stdio::null())
31 36
            .stderr(std::process::Stdio::null())
32 37
            .spawn()
…
1 unchanged lines
34 39
    {
35 40
        println!("  {} {}", ui::note(), ui::dim("opened in your browser"));
36 41
    }
42 +
}
43 +
44 +
/// `spectra open` with Spectra on another machine (spec §8.6): serve Radar
45 +
/// through a loopback proxy to the pinned server, and sign in through it.
46 +
pub async fn open_remote(ctx: &Ctx, owner_home_flag: Option<PathBuf>, launch: bool, port: u16) -> Result<i32> {
47 +
    let cfg = ctx.remote()?.context("no server configured")?;
48 +
    let home = owner_home(ctx, owner_home_flag);
49 +
    let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
50 +
        .await
51 +
        .with_context(|| format!("binding 127.0.0.1:{port} for Radar (another `spectra open` running? --port picks another)"))?;
52 +
    let port = listener.local_addr()?.port();
53 +
    let client = ctx.owner_client(&home).await?.context("no server configured")?;
54 +
    let r = client.send("POST", "/radar/link", &[], Vec::new()).await?;
55 +
    if r.status != 200 {
56 +
        bail!("the server refused the sign-in: {}", String::from_utf8_lossy(&r.body));
57 +
    }
58 +
    let url = r.json()?.get("url").and_then(|u| u.as_str()).map(String::from).context("no link in the answer")?;
59 +
    let local = spectra_mcp::proxy::local_link(&url, port);
60 +
    println!("{} {}{}{}", ui::done("Serving"), ui::subject("Radar"), ui::sep(), ui::dim(format!("from {} over pinned TLS", cfg.server)));
61 +
    println!("{}", ui::kv("radar", format!("http://127.0.0.1:{port}")));
62 +
    println!("{} {}", ui::done("Signed"), ui::subject("a one-time link, good for a minute"));
63 +
    show(&local, launch);
64 +
    println!("  {} {}", ui::note(), ui::dim("Ctrl-C stops the proxy"));
65 +
    use std::io::Write;
66 +
    std::io::stdout().flush().ok();
67 +
    let app = spectra_mcp::proxy::router(cfg.endpoint()?, port);
68 +
    axum::serve(listener, app)
69 +
        .with_graceful_shutdown(async {
70 +
            let _ = tokio::signal::ctrl_c().await;
71 +
        })
72 +
        .await?;
37 73
    Ok(0)
38 74
}
modified tests/client.rs +87 −0
…
103 unchanged lines
104 104
    let o = run(s, &["admin", "verify"]);
105 105
    assert!(o.status.success(), "{}", text(&o));
106 106
}
107 +
108 +
/// One HTTP/1.1 exchange with the proxy: status, head, body.
109 +
fn http(port: u16, method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> (u16, String, String) {
110 +
    use std::io::{Read, Write};
111 +
    let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
112 +
    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
113 +
    let mut req = format!("{method} {path} HTTP/1.1\r\nconnection: close\r\ncontent-length: {}\r\n", body.len());
114 +
    if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("host")) {
115 +
        req.push_str(&format!("host: 127.0.0.1:{port}\r\n"));
116 +
    }
117 +
    for (k, v) in headers {
118 +
        req.push_str(&format!("{k}: {v}\r\n"));
119 +
    }
120 +
    req.push_str("\r\n");
121 +
    req.push_str(body);
122 +
    s.write_all(req.as_bytes()).unwrap();
123 +
    let mut out = Vec::new();
124 +
    let _ = s.read_to_end(&mut out);
125 +
    let text = String::from_utf8_lossy(&out).into_owned();
126 +
    let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
127 +
    let status = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
128 +
    (status, head.to_string(), body.to_string())
129 +
}
130 +
131 +
#[tokio::test]
132 +
async fn radar_through_the_loopback_proxy() {
133 +
    use std::io::BufRead;
134 +
    let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
135 +
    let (s, c) = (sd.path(), cd.path());
136 +
    let (srv, _port) = connected(s, c);
137 +
138 +
    let mut open = hermetic(c).args(["open", "--no-browser", "--port", "0"]).stdout(Stdio::piped()).spawn().unwrap();
139 +
    let out = open.stdout.take().unwrap();
140 +
    let (tx, rx) = std::sync::mpsc::channel();
141 +
    std::thread::spawn(move || {
142 +
        for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {
143 +
            if let Some(u) = line.split_whitespace().find(|w| w.contains("/signin?code=")) {
144 +
                let _ = tx.send(u.to_string());
145 +
            }
146 +
        }
147 +
    });
148 +
    let link = rx.recv_timeout(Duration::from_secs(10)).expect("spectra open printed a link");
149 +
    let rest = link.strip_prefix("http://127.0.0.1:").expect("a loopback link");
150 +
    let (port, path) = rest.split_once('/').unwrap();
151 +
    let port: u16 = port.parse().unwrap();
152 +
153 +
    // Signing in through the proxy sets the session cookie.
154 +
    let (status, head, _) = http(port, "GET", &format!("/{path}"), &[], "");
155 +
    assert_eq!(status, 303, "{head}");
156 +
    let cookie = head
157 +
        .lines()
158 +
        .find_map(|l| {
159 +
            l.to_ascii_lowercase()
160 +
                .starts_with("set-cookie:")
161 +
                .then(|| l.split_once(':').unwrap().1.trim().split(';').next().unwrap().to_string())
162 +
        })
163 +
        .expect("a session cookie");
164 +
165 +
    // Live and a channel load; the channel's form carries the CSRF token.
166 +
    let (status, _, live) = http(port, "GET", "/", &[("cookie", &cookie)], "");
167 +
    assert_eq!(status, 200);
168 +
    assert!(live.contains("Spectra"), "{live}");
169 +
    let (status, _, page) = http(port, "GET", "/c/general", &[("cookie", &cookie)], "");
170 +
    assert_eq!(status, 200);
171 +
    let csrf = page.split("name=\"_csrf\" value=\"").nth(1).and_then(|r| r.split('"').next()).expect("a CSRF token").to_string();
172 +
173 +
    // A post from the proxy's own page goes through, as the owner.
174 +
    let origin = format!("http://127.0.0.1:{port}");
175 +
    let form = format!("_csrf={csrf}&body=hello+from+radar+over+the+proxy");
176 +
    let hdrs = [("cookie", cookie.as_str()), ("origin", origin.as_str()), ("content-type", "application/x-www-form-urlencoded")];
177 +
    let (status, head, _) = http(port, "POST", "/c/general", &hdrs, &form);
178 +
    assert_eq!(status, 303, "{head}");
179 +
    let hits = json_of(c, &["search", "--json", "proxy"]);
180 +
    assert_eq!(hits.as_array().unwrap().len(), 1, "{hits}");
181 +
    assert_eq!(hits[0]["author"], "human");
182 +
183 +
    // Another site cannot use the proxy: not by Origin, nor by Host.
184 +
    let evil = [("cookie", cookie.as_str()), ("origin", "http://evil.example"), ("content-type", "application/x-www-form-urlencoded")];
185 +
    assert_eq!(http(port, "POST", "/c/general", &evil, &form).0, 403);
186 +
    assert_eq!(http(port, "GET", "/", &[("host", "evil.example"), ("cookie", &cookie)], "").0, 403);
187 +
188 +
    open.kill().ok();
189 +
    open.wait().ok();
190 +
    srv.stop();
191 +
    let o = run(s, &["admin", "verify"]);
192 +
    assert!(o.status.success(), "{}", text(&o));
193 +
}