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 | + | } |