OXYGENHUB

Serve other machines over TLS, trusted through the instance key (#20) b576d05cf862

The daemon makes a certificate of its own and vouches for it with a
signed instance.tls event; GET /api/v1/instance lets a client check that
before trusting anything; clients pin the certificate. A uid-bound agent
is refused over TCP and recorded as guard.impersonation.

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 ce638315e1a7
When
· 2026-11-03 13:06 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b576d05cf862371c8144d0963302ddc5de769bcf4557ab4d6b7ea7ae4c7684fc
Depends on
037c45d8093c 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 39f5c913f617 571572f5fb2c 8dc6df8c4d4e 98a16830a3d3 d17cbc7069ce da84e98f104e e071e91a14ef
Depended on by
862f0db224aa c7161ec1a607 98a0fb9f0948 eca5af92279b 77df591d5a2f
In bookmarks
main
In tags
v0.1.0
Edits
71 atoms, 3 tree operations, 16 files

Browse the tree at this change

added crates/spectra-mcp/src/instance.rs +79 −0
1 +
//! The instance's word for its TLS certificate (spec §8.6): the
2 +
//! `instance.tls` event the daemon signs with the instance key, and the
3 +
//! check a client makes of it.
4 +
5 +
use anyhow::{bail, Context, Result};
6 +
use oxy_core::identity::Identity;
7 +
use serde_json::{json, Value as Json};
8 +
use spectra_core::event::Event;
9 +
use spectra_core::keys::KeyPair;
10 +
use spectra_core::ledger::Ledger;
11 +
use spectra_core::proof::Proof;
12 +
13 +
/// The latest `instance.tls` event, if any.
14 +
pub async fn latest_tls(ledger: &Ledger) -> Result<Option<Event>> {
15 +
    let mut last = None;
16 +
    let mut from = 1;
17 +
    loop {
18 +
        let rows = ledger.events_of_kinds(from, &["instance.tls"], 1000).await?;
19 +
        match rows.last() {
20 +
            Some(e) => {
21 +
                from = e.seq + 1;
22 +
                last = Some(e.clone());
23 +
            }
24 +
            None => return Ok(last),
25 +
        }
26 +
    }
27 +
}
28 +
29 +
/// Record the certificate's pin, signed by the instance key, unless the
30 +
/// latest record already names it.
31 +
pub async fn record_tls(ledger: &Ledger, instance: &KeyPair, spki: &[u8; 32], names: &[String]) -> Result<bool> {
32 +
    let hex_spki = hex::encode(spki);
33 +
    if let Some(e) = latest_tls(ledger).await? {
34 +
        if e.json().get("spki_sha256").and_then(Json::as_str) == Some(hex_spki.as_str()) {
35 +
            return Ok(false);
36 +
        }
37 +
    }
38 +
    let payload = json!({ "spki_sha256": hex_spki, "names": names });
39 +
    ledger.append_one(spectra_core::principals::signed_event(instance, "system", "instance.tls", payload)).await?;
40 +
    Ok(true)
41 +
}
42 +
43 +
/// What `GET /api/v1/instance` told a client, checked.
44 +
#[derive(Clone, Debug)]
45 +
pub struct Checked {
46 +
    pub name: String,
47 +
    pub instance: Identity,
48 +
    pub owner: Option<String>,
49 +
    /// The certificate the instance key vouches for.
50 +
    pub spki_sha256: [u8; 32],
51 +
}
52 +
53 +
/// Check `/api/v1/instance`'s answer: the identity is whole, and the
54 +
/// instance key signed an `instance.tls` naming the certificate `seen`.
55 +
pub fn check(answer: &Json, seen: &[u8; 32]) -> Result<Checked> {
56 +
    let ip = answer.get("instance").context("no instance in the answer")?;
57 +
    let bytes = ip.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).context("no instance identity")?;
58 +
    let instance = Identity::decode(&bytes).map_err(|e| anyhow::anyhow!("malformed instance identity: {e}"))?;
59 +
    let tls = answer.get("tls").filter(|t| !t.is_null()).context("the server has not signed a TLS certificate")?;
60 +
    let payload = tls.get("payload").context("no TLS payload")?;
61 +
    let proof = tls.get("proof").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).context("no TLS proof")?;
62 +
    let proof = Proof::decode(&proof).map_err(|e| anyhow::anyhow!(e))?;
63 +
    if proof.key() != instance.hash().0 || proof.method != "cli" {
64 +
        bail!("the certificate is not vouched for by the instance key");
65 +
    }
66 +
    proof
67 +
        .verify(&instance, Some(&spectra_core::event::canonical_json(payload)))
68 +
        .map_err(|e| anyhow::anyhow!("the instance's signature on its certificate: {e}"))?;
69 +
    let named = payload.get("spki_sha256").and_then(Json::as_str).context("no pin in the TLS record")?;
70 +
    if named != hex::encode(seen) {
71 +
        bail!("the server presented a certificate its instance key did not sign");
72 +
    }
73 +
    Ok(Checked {
74 +
        name: answer.get("name").and_then(Json::as_str).unwrap_or("").to_string(),
75 +
        instance,
76 +
        owner: answer.get("owner").and_then(Json::as_str).map(String::from),
77 +
        spki_sha256: *seen,
78 +
    })
79 +
}
added crates/spectra-mcp/src/tls.rs +264 −0
1 +
//! TLS for client and server (spec §8.6). The server makes a certificate
2 +
//! of its own; the instance key vouches for it with an `instance.tls`
3 +
//! event; a client trusts that one certificate, pinned by the SHA-256 of
4 +
//! its SubjectPublicKeyInfo, and never a certificate authority.
5 +
6 +
use anyhow::{bail, Context, Result};
7 +
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
8 +
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
9 +
use rustls::{DigitallySignedStruct, SignatureScheme};
10 +
use std::net::SocketAddr;
11 +
use std::path::{Path, PathBuf};
12 +
use std::sync::{Arc, Mutex};
13 +
use std::time::Duration;
14 +
use tokio::net::{TcpListener, TcpStream};
15 +
16 +
/// The crypto provider: ring, as OxygenHub's.
17 +
pub fn provider() -> Arc<rustls::crypto::CryptoProvider> {
18 +
    Arc::new(rustls::crypto::ring::default_provider())
19 +
}
20 +
21 +
/// SHA-256 of a certificate's SubjectPublicKeyInfo: what a client pins.
22 +
pub fn spki_sha256(cert_der: &[u8]) -> Result<[u8; 32]> {
23 +
    let (_, cert) = x509_parser::parse_x509_certificate(cert_der).map_err(|e| anyhow::anyhow!("not an X.509 certificate: {e}"))?;
24 +
    let d = ring::digest::digest(&ring::digest::SHA256, cert.public_key().raw);
25 +
    Ok(d.as_ref().try_into().expect("SHA-256 is 32 bytes"))
26 +
}
27 +
28 +
/// Where a server's certificate comes from (`[server] tls`).
29 +
#[derive(Clone, Debug)]
30 +
pub enum CertSource {
31 +
    /// Made on first start in `<data>/keys/tls.{key,crt}`, naming these names.
32 +
    Own { names: Vec<String> },
33 +
    /// Files from elsewhere (PEM).
34 +
    Files { cert: PathBuf, key: PathBuf },
35 +
}
36 +
37 +
/// A server's TLS: its rustls configuration and the certificate's pin.
38 +
pub struct ServerTls {
39 +
    pub config: Arc<rustls::ServerConfig>,
40 +
    pub spki_sha256: [u8; 32],
41 +
    pub names: Vec<String>,
42 +
}
43 +
44 +
impl ServerTls {
45 +
    pub fn load(data: &Path, source: &CertSource) -> Result<ServerTls> {
46 +
        let (cert_pem, key_pem, names) = match source {
47 +
            CertSource::Own { names } => {
48 +
                let keys = data.join("keys");
49 +
                let (crt, key) = (keys.join("tls.crt"), keys.join("tls.key"));
50 +
                if !crt.exists() || !key.exists() {
51 +
                    make_own(&crt, &key, names)?;
52 +
                }
53 +
                (std::fs::read_to_string(&crt)?, std::fs::read_to_string(&key)?, names.clone())
54 +
            }
55 +
            CertSource::Files { cert, key } => (
56 +
                std::fs::read_to_string(cert).with_context(|| format!("reading {}", cert.display()))?,
57 +
                std::fs::read_to_string(key).with_context(|| format!("reading {}", key.display()))?,
58 +
                vec![],
59 +
            ),
60 +
        };
61 +
        let chain: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_pem.as_bytes()).collect::<Result<_, _>>()?;
62 +
        let leaf = chain.first().context("no certificate in the PEM")?;
63 +
        let spki = spki_sha256(leaf)?;
64 +
        let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_pem.as_bytes())?.context("no private key in the PEM")?;
65 +
        let mut config = rustls::ServerConfig::builder_with_provider(provider())
66 +
            .with_safe_default_protocol_versions()?
67 +
            .with_no_client_auth()
68 +
            .with_single_cert(chain, key)?;
69 +
        config.alpn_protocols = vec![b"http/1.1".to_vec()];
70 +
        Ok(ServerTls { config: Arc::new(config), spki_sha256: spki, names })
71 +
    }
72 +
}
73 +
74 +
fn make_own(crt: &Path, key: &Path, names: &[String]) -> Result<()> {
75 +
    if names.is_empty() {
76 +
        bail!("a certificate needs at least one name ([server] names)");
77 +
    }
78 +
    let ck = rcgen::generate_simple_self_signed(names.to_vec())?;
79 +
    if let Some(dir) = crt.parent() {
80 +
        std::fs::create_dir_all(dir)?;
81 +
    }
82 +
    write_private(key, ck.signing_key.serialize_pem().as_bytes())?;
83 +
    std::fs::write(crt, ck.cert.pem())?;
84 +
    Ok(())
85 +
}
86 +
87 +
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
88 +
    #[cfg(unix)]
89 +
    {
90 +
        use std::io::Write;
91 +
        use std::os::unix::fs::OpenOptionsExt;
92 +
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
93 +
        f.write_all(bytes)?;
94 +
        Ok(())
95 +
    }
96 +
    #[cfg(not(unix))]
97 +
    {
98 +
        std::fs::write(path, bytes)?;
99 +
        Ok(())
100 +
    }
101 +
}
102 +
103 +
/// A TCP listener that hands axum connections once their TLS handshake is
104 +
/// done; handshakes run concurrently, so a slow one holds up nobody.
105 +
pub struct TlsListener {
106 +
    rx: tokio::sync::mpsc::Receiver<(tokio_rustls::server::TlsStream<TcpStream>, SocketAddr)>,
107 +
    addr: SocketAddr,
108 +
}
109 +
110 +
impl TlsListener {
111 +
    pub fn new(tcp: TcpListener, config: Arc<rustls::ServerConfig>) -> Result<TlsListener> {
112 +
        let addr = tcp.local_addr()?;
113 +
        let acceptor = tokio_rustls::TlsAcceptor::from(config);
114 +
        let (tx, rx) = tokio::sync::mpsc::channel(64);
115 +
        tokio::spawn(async move {
116 +
            loop {
117 +
                let (stream, peer) = match tcp.accept().await {
118 +
                    Ok(x) => x,
119 +
                    Err(e) => {
120 +
                        tracing::warn!("accept: {e}");
121 +
                        tokio::time::sleep(Duration::from_millis(50)).await;
122 +
                        continue;
123 +
                    }
124 +
                };
125 +
                let (acceptor, out) = (acceptor.clone(), tx.clone());
126 +
                tokio::spawn(async move {
127 +
                    match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(stream)).await {
128 +
                        Ok(Ok(tls)) => {
129 +
                            let _ = out.send((tls, peer)).await;
130 +
                        }
131 +
                        Ok(Err(e)) => tracing::debug!(%peer, "TLS handshake failed: {e}"),
132 +
                        Err(_) => tracing::debug!(%peer, "TLS handshake timed out"),
133 +
                    }
134 +
                });
135 +
                if tx.is_closed() {
136 +
                    return;
137 +
                }
138 +
            }
139 +
        });
140 +
        Ok(TlsListener { rx, addr })
141 +
    }
142 +
}
143 +
144 +
impl axum::serve::Listener for TlsListener {
145 +
    type Io = tokio_rustls::server::TlsStream<TcpStream>;
146 +
    type Addr = SocketAddr;
147 +
148 +
    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
149 +
        match self.rx.recv().await {
150 +
            Some(x) => x,
151 +
            // The accept loop never ends while we hold the receiver.
152 +
            None => std::future::pending().await,
153 +
        }
154 +
    }
155 +
156 +
    fn local_addr(&self) -> std::io::Result<Self::Addr> {
157 +
        Ok(self.addr)
158 +
    }
159 +
}
160 +
161 +
/// A server certificate verifier that accepts exactly one public key, or,
162 +
/// with no pin, any key, remembering it for the caller to check
163 +
/// (`spectra connect`). The handshake's signature is always checked, so
164 +
/// the server must hold the certificate's private key.
165 +
#[derive(Debug)]
166 +
pub struct Pinned {
167 +
    pin: Option<[u8; 32]>,
168 +
    seen: Mutex<Option<(Vec<u8>, [u8; 32])>>,
169 +
    algs: rustls::crypto::WebPkiSupportedAlgorithms,
170 +
}
171 +
172 +
impl Pinned {
173 +
    pub fn new(pin: Option<[u8; 32]>) -> Arc<Pinned> {
174 +
        Arc::new(Pinned { pin, seen: Mutex::new(None), algs: provider().signature_verification_algorithms })
175 +
    }
176 +
177 +
    /// The certificate (DER) and its pin from the last handshake.
178 +
    pub fn seen(&self) -> Option<(Vec<u8>, [u8; 32])> {
179 +
        self.seen.lock().unwrap().clone()
180 +
    }
181 +
182 +
    pub fn client_config(self: &Arc<Self>) -> Result<Arc<rustls::ClientConfig>> {
183 +
        let mut c = rustls::ClientConfig::builder_with_provider(provider())
184 +
            .with_safe_default_protocol_versions()?
185 +
            .dangerous()
186 +
            .with_custom_certificate_verifier(self.clone())
187 +
            .with_no_client_auth();
188 +
        c.alpn_protocols = vec![b"http/1.1".to_vec()];
189 +
        Ok(Arc::new(c))
190 +
    }
191 +
}
192 +
193 +
impl ServerCertVerifier for Pinned {
194 +
    fn verify_server_cert(
195 +
        &self,
196 +
        end_entity: &CertificateDer<'_>,
197 +
        _intermediates: &[CertificateDer<'_>],
198 +
        _server_name: &ServerName<'_>,
199 +
        _ocsp: &[u8],
200 +
        _now: UnixTime,
201 +
    ) -> Result<ServerCertVerified, rustls::Error> {
202 +
        let spki = spki_sha256(end_entity).map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?;
203 +
        *self.seen.lock().unwrap() = Some((end_entity.to_vec(), spki));
204 +
        match self.pin {
205 +
            Some(pin) if pin != spki => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure)),
206 +
            _ => Ok(ServerCertVerified::assertion()),
207 +
        }
208 +
    }
209 +
210 +
    fn verify_tls12_signature(
211 +
        &self,
212 +
        message: &[u8],
213 +
        cert: &CertificateDer<'_>,
214 +
        dss: &DigitallySignedStruct,
215 +
    ) -> Result<HandshakeSignatureValid, rustls::Error> {
216 +
        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algs)
217 +
    }
218 +
219 +
    fn verify_tls13_signature(
220 +
        &self,
221 +
        message: &[u8],
222 +
        cert: &CertificateDer<'_>,
223 +
        dss: &DigitallySignedStruct,
224 +
    ) -> Result<HandshakeSignatureValid, rustls::Error> {
225 +
        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algs)
226 +
    }
227 +
228 +
    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
229 +
        self.algs.supported_schemes()
230 +
    }
231 +
}
232 +
233 +
/// Connect to `host:port` over TLS with `config`.
234 +
pub async fn connect(addr: &str, config: Arc<rustls::ClientConfig>) -> Result<tokio_rustls::client::TlsStream<TcpStream>> {
235 +
    let tcp = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(addr))
236 +
        .await
237 +
        .with_context(|| format!("connecting to {addr}: timed out"))?
238 +
        .with_context(|| format!("connecting to {addr}"))?;
239 +
    tcp.set_nodelay(true).ok();
240 +
    let host = addr.rsplit_once(':').map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']')).unwrap_or(addr);
241 +
    let name = ServerName::try_from(host.to_string()).unwrap_or_else(|_| ServerName::try_from("spectra").unwrap());
242 +
    let tls = tokio_rustls::TlsConnector::from(config).connect(name, tcp).await.with_context(|| format!("TLS with {addr}"))?;
243 +
    Ok(tls)
244 +
}
245 +
246 +
#[cfg(test)]
247 +
mod tests {
248 +
    use super::*;
249 +
250 +
    #[test]
251 +
    fn own_certificate_is_made_once_and_pinned() {
252 +
        let dir = tempfile::tempdir().unwrap();
253 +
        let src = CertSource::Own { names: vec!["localhost".into()] };
254 +
        let a = ServerTls::load(dir.path(), &src).unwrap();
255 +
        let b = ServerTls::load(dir.path(), &src).unwrap();
256 +
        assert_eq!(a.spki_sha256, b.spki_sha256, "the certificate is kept between starts");
257 +
        #[cfg(unix)]
258 +
        {
259 +
            use std::os::unix::fs::PermissionsExt;
260 +
            let mode = std::fs::metadata(dir.path().join("keys/tls.key")).unwrap().permissions().mode();
261 +
            assert_eq!(mode & 0o777, 0o600);
262 +
        }
263 +
    }
264 +
}
added tests/tls.rs +98 −0
1 +
//! The daemon over TLS (spec §8.6, spectra#20): a certificate of its own,
2 +
//! vouched for by the instance key; a client that trusts that, and only
3 +
//! that; signed MCP calls over the network; no uid-bound agent over TCP.
4 +
5 +
mod common;
6 +
7 +
use common::{add_agent, run, setup, text, Serving};
8 +
use serde_json::json;
9 +
use spectra_core::keys::KeyPair;
10 +
use spectra_mcp::client::{rpc_result, Client, Endpoint};
11 +
use spectra_mcp::tls::Pinned;
12 +
13 +
fn unpinned(port: u16) -> (Client, std::sync::Arc<Pinned>) {
14 +
    let v = Pinned::new(None);
15 +
    let e = Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: v.client_config().unwrap() };
16 +
    (Client::at(e, None), v)
17 +
}
18 +
19 +
#[tokio::test]
20 +
async fn a_client_trusts_the_certificate_the_instance_key_signed() {
21 +
    let dir = setup();
22 +
    let d = dir.path();
23 +
    let agent = add_agent(d, "claude-oxygen", &[]);
24 +
    let (srv, port) = Serving::start_tls(d);
25 +
26 +
    // Unauthenticated, before trusting anything: who is this?
27 +
    let (c, seen) = unpinned(port);
28 +
    let r = c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap();
29 +
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
30 +
    let answer = r.json().unwrap();
31 +
    let (_, pin) = seen.seen().expect("the handshake showed a certificate");
32 +
    let checked = spectra_mcp::instance::check(&answer, &pin).unwrap();
33 +
    let instance = KeyPair::instance(&d.join("data"), "spectra").unwrap();
34 +
    assert_eq!(checked.instance.hash(), instance.key, "the instance key vouches for the certificate");
35 +
    let owner = KeyPair::load_home(&d.join("owner")).unwrap();
36 +
    assert_eq!(checked.owner.as_deref(), Some(owner.key.to_hex().as_str()));
37 +
38 +
    // A certificate the instance did not sign is not accepted.
39 +
    assert!(spectra_mcp::instance::check(&answer, &[7; 32]).is_err());
40 +
41 +
    // Pinned, an agent's signed MCP call works over the network.
42 +
    let c = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), pin).unwrap(), Some(agent));
43 +
    let call = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "agents", "arguments": {},
44 +
        "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
45 +
    let r = c.mcp("2026-07-28", &call).await.unwrap();
46 +
    let v = rpc_result(&r).unwrap();
47 +
    assert_eq!(v["result"]["isError"], false, "{v}");
48 +
    assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("claude-oxygen · Andy McCall's agent"));
49 +
50 +
    // Any other certificate is refused at the handshake.
51 +
    let wrong = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), [0; 32]).unwrap(), None);
52 +
    assert!(wrong.send("GET", "/api/v1/instance", &[], vec![]).await.is_err());
53 +
    srv.stop();
54 +
55 +
    // The certificate is kept, and recorded once, across restarts; verify
56 +
    // checks the instance's signature on it.
57 +
    let (srv, port2) = Serving::start_tls(d);
58 +
    let (c, seen) = unpinned(port2);
59 +
    c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap();
60 +
    assert_eq!(seen.seen().unwrap().1, pin);
61 +
    srv.stop();
62 +
    let o = run(d, &["admin", "verify"]);
63 +
    assert!(o.status.success(), "{}", text(&o));
64 +
}
65 +
66 +
#[tokio::test]
67 +
async fn an_agent_bound_to_a_uid_is_refused_over_the_network() {
68 +
    let dir = setup();
69 +
    let d = dir.path();
70 +
    let uid = unsafe { libc::getuid() }.to_string();
71 +
    let bound = add_agent(d, "bound", &["--uid", &uid]);
72 +
    let (srv, port) = Serving::start_tls(d);
73 +
    let (probe, seen) = unpinned(port);
74 +
    probe.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap();
75 +
    let pin = seen.seen().unwrap().1;
76 +
    let c = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), pin).unwrap(), Some(bound.clone()));
77 +
    let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap();
78 +
    assert_eq!(r.status, 401);
79 +
    assert!(String::from_utf8_lossy(&r.body).contains("must come over the Unix socket"), "{}", String::from_utf8_lossy(&r.body));
80 +
    // Over the socket, from its own uid, it is welcome.
81 +
    let local = Client::new(&srv.socket, Some(bound));
82 +
    let r = local.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap();
83 +
    assert_eq!(r.status, 200);
84 +
    srv.stop();
85 +
    let o = run(d, &["admin", "verify"]);
86 +
    assert!(o.status.success(), "{}", text(&o));
87 +
    let log = spectra_core::ledger::Ledger::open_read_only(&d.join("data/spectra.db")).await.unwrap();
88 +
    let g = log.events_of_kinds(1, &["guard.impersonation"], 10).await.unwrap();
89 +
    assert_eq!(g.len(), 1, "recorded as an impersonation attempt");
90 +
}
91 +
92 +
#[test]
93 +
fn plain_http_beyond_loopback_is_refused() {
94 +
    let dir = setup();
95 +
    let o = run(dir.path(), &["serve", "--bind", "0.0.0.0:0", "--tls", "off"]);
96 +
    assert!(!o.status.success());
97 +
    assert!(text(&o).contains("over TLS only"), "{}", text(&o));
98 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check.
5 6
- **Added: the README's quick start, a systemd user unit (`packaging/spectra.service`) and `tests/bench.rs`** for the targets of spec §24 (brief 2.7 ms, say 3.1 ms, wait wakes 4.2 ms after a mention, with 1,000 messages, in release).
6 7
- **Added: the human at a terminal (#12).** `spectra say [-c CHANNEL] [--reply-to ID] BODY`, `spectra inbox [--follow]`, `spectra search QUERY [--channel] [--author] [--since 7d]` and `spectra api METHOD PATH [-d JSON]`, each signing with the owner's own key: through the daemon when it is serving, else straight into the ledger. Every listing takes `--json` (one document); `say` and `inbox --follow` make a terminal chat with the agents.
7 8
- **Added: the JSON API and its TypeScript types (#11).** `/api/v1` has health, version, brief, agents, projects, channels, a channel's messages (read and post), threads, the inbox and ack, search, and the event log, which follows live as SSE (`?follow=1`). Requests are signed, or a read carries the owner's Radar session; refusals are RFC 7807 problems; lists page with `?after=`; reads carry an ETag. `crates/spectra-api` holds the types, and `make ts` writes `ts/index.d.ts` for the editor extensions.
…
12 unchanged lines
modified Cargo.lock +259 −14
…
139 unchanged lines
140 140
]
141 141
142 142
[[package]]
143 +
name = "asn1-rs"
144 +
version = "0.7.2"
145 +
source = "registry+https://github.com/rust-lang/crates.io-index"
146 +
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
147 +
dependencies = [
148 +
 "asn1-rs-derive",
149 +
 "asn1-rs-impl",
150 +
 "displaydoc",
151 +
 "nom",
152 +
 "num-traits",
153 +
 "rusticata-macros",
154 +
 "thiserror 2.0.21",
155 +
 "time",
156 +
]
157 +
158 +
[[package]]
159 +
name = "asn1-rs-derive"
160 +
version = "0.6.0"
161 +
source = "registry+https://github.com/rust-lang/crates.io-index"
162 +
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
163 +
dependencies = [
164 +
 "proc-macro2",
165 +
 "quote",
166 +
 "syn 2.0.119",
167 +
 "synstructure 0.13.2",
168 +
]
169 +
170 +
[[package]]
171 +
name = "asn1-rs-impl"
172 +
version = "0.2.0"
173 +
source = "registry+https://github.com/rust-lang/crates.io-index"
174 +
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
175 +
dependencies = [
176 +
 "proc-macro2",
177 +
 "quote",
178 +
 "syn 2.0.119",
179 +
]
180 +
181 +
[[package]]
143 182
name = "atoi"
144 183
version = "2.0.0"
145 184
source = "registry+https://github.com/rust-lang/crates.io-index"
…
85 unchanged lines
231 270
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
232 271
233 272
[[package]]
273 +
name = "base64"
274 +
version = "0.23.1"
275 +
source = "registry+https://github.com/rust-lang/crates.io-index"
276 +
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
277 +
278 +
[[package]]
234 279
name = "base64ct"
235 280
version = "1.8.3"
236 281
source = "registry+https://github.com/rust-lang/crates.io-index"
…
14 unchanged lines
251 296
source = "registry+https://github.com/rust-lang/crates.io-index"
252 297
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
253 298
dependencies = [
254 -
 "bit-vec",
299 +
 "bit-vec 0.8.0",
255 300
]
256 301
257 302
[[package]]
…
3 unchanged lines
261 306
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
262 307
263 308
[[package]]
309 +
name = "bit-vec"
310 +
version = "0.9.1"
311 +
source = "registry+https://github.com/rust-lang/crates.io-index"
312 +
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
313 +
dependencies = [
314 +
 "serde",
315 +
]
316 +
317 +
[[package]]
264 318
name = "bitflags"
265 319
version = "2.13.2"
266 320
source = "registry+https://github.com/rust-lang/crates.io-index"
…
306 unchanged lines
573 627
 "quote",
574 628
 "syn 2.0.119",
575 629
]
630 +
631 +
[[package]]
632 +
name = "data-encoding"
633 +
version = "2.11.1"
634 +
source = "registry+https://github.com/rust-lang/crates.io-index"
635 +
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
576 636
577 637
[[package]]
578 638
name = "der"
…
14 unchanged lines
593 653
dependencies = [
594 654
 "const-oid 0.10.2",
595 655
 "zeroize",
656 +
]
657 +
658 +
[[package]]
659 +
name = "der-parser"
660 +
version = "10.0.0"
661 +
source = "registry+https://github.com/rust-lang/crates.io-index"
662 +
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
663 +
dependencies = [
664 +
 "asn1-rs",
665 +
 "displaydoc",
666 +
 "nom",
667 +
 "num-bigint",
668 +
 "num-traits",
669 +
 "rusticata-macros",
596 670
]
671 +
672 +
[[package]]
673 +
name = "deranged"
674 +
version = "0.5.8"
675 +
source = "registry+https://github.com/rust-lang/crates.io-index"
676 +
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
597 677
598 678
[[package]]
599 679
name = "digest"
…
106 unchanged lines
706 786
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
707 787
dependencies = [
708 788
 "libc",
709 -
 "windows-sys 0.61.2",
789 +
 "windows-sys 0.59.0",
710 790
]
711 791
712 792
[[package]]
…
730 unchanged lines
1443 1523
version = "0.3.17"
1444 1524
source = "registry+https://github.com/rust-lang/crates.io-index"
1445 1525
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1526 +
1527 +
[[package]]
1528 +
name = "minimal-lexical"
1529 +
version = "0.2.1"
1530 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1531 +
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1446 1532
1447 1533
[[package]]
1448 1534
name = "miniz_oxide"
…
50 unchanged lines
1499 1585
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
1500 1586
1501 1587
[[package]]
1588 +
name = "nom"
1589 +
version = "7.1.3"
1590 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1591 +
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1592 +
dependencies = [
1593 +
 "memchr",
1594 +
 "minimal-lexical",
1595 +
]
1596 +
1597 +
[[package]]
1502 1598
name = "nu-ansi-term"
1503 1599
version = "0.50.3"
1504 1600
source = "registry+https://github.com/rust-lang/crates.io-index"
1505 1601
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1506 1602
dependencies = [
1507 -
 "windows-sys 0.61.2",
1603 +
 "windows-sys 0.59.0",
1508 1604
]
1509 1605
1510 1606
[[package]]
1607 +
name = "num-bigint"
1608 +
version = "0.4.8"
1609 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1610 +
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1611 +
dependencies = [
1612 +
 "num-integer",
1613 +
 "num-traits",
1614 +
]
1615 +
1616 +
[[package]]
1511 1617
name = "num-bigint-dig"
1512 1618
version = "0.8.6"
1513 1619
source = "registry+https://github.com/rust-lang/crates.io-index"
…
10 unchanged lines
1524 1630
]
1525 1631
1526 1632
[[package]]
1633 +
name = "num-conv"
1634 +
version = "0.2.2"
1635 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1636 +
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1637 +
1638 +
[[package]]
1527 1639
name = "num-integer"
1528 1640
version = "0.1.47"
1529 1641
source = "registry+https://github.com/rust-lang/crates.io-index"
…
27 unchanged lines
1557 1669
version = "0.4.0"
1558 1670
source = "registry+https://github.com/rust-lang/crates.io-index"
1559 1671
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
1672 +
1673 +
[[package]]
1674 +
name = "oid-registry"
1675 +
version = "0.8.1"
1676 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1677 +
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
1678 +
dependencies = [
1679 +
 "asn1-rs",
1680 +
]
1560 1681
1561 1682
[[package]]
1562 1683
name = "once_cell"
…
61 unchanged lines
1624 1745
 "redox_syscall 0.5.18",
1625 1746
 "smallvec",
1626 1747
 "windows-link",
1748 +
]
1749 +
1750 +
[[package]]
1751 +
name = "pem"
1752 +
version = "4.0.0"
1753 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1754 +
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
1755 +
dependencies = [
1756 +
 "base64 0.23.1",
1757 +
 "serde_core",
1627 1758
]
1628 1759
1629 1760
[[package]]
…
113 unchanged lines
1743 1874
dependencies = [
1744 1875
 "zerovec",
1745 1876
]
1877 +
1878 +
[[package]]
1879 +
name = "powerfmt"
1880 +
version = "0.2.1"
1881 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1882 +
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
1746 1883
1747 1884
[[package]]
1748 1885
name = "ppv-lite86"
…
26 unchanged lines
1775 1912
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
1776 1913
dependencies = [
1777 1914
 "bit-set",
1778 -
 "bit-vec",
1915 +
 "bit-vec 0.8.0",
1779 1916
 "bitflags",
1780 1917
 "num-traits",
1781 1918
 "rand 0.9.5",
…
165 unchanged lines
1947 2084
dependencies = [
1948 2085
 "crossbeam-deque",
1949 2086
 "crossbeam-utils",
2087 +
]
2088 +
2089 +
[[package]]
2090 +
name = "rcgen"
2091 +
version = "0.14.10"
2092 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2093 +
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
2094 +
dependencies = [
2095 +
 "pem",
2096 +
 "ring",
2097 +
 "rustls-pki-types",
2098 +
 "time",
2099 +
 "x509-parser",
2100 +
 "yasna",
1950 2101
]
1951 2102
1952 2103
[[package]]
…
101 unchanged lines
2054 2205
]
2055 2206
2056 2207
[[package]]
2208 +
name = "rusticata-macros"
2209 +
version = "4.1.0"
2210 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2211 +
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
2212 +
dependencies = [
2213 +
 "nom",
2214 +
]
2215 +
2216 +
[[package]]
2057 2217
name = "rustix"
2058 2218
version = "1.1.5"
2059 2219
source = "registry+https://github.com/rust-lang/crates.io-index"
…
3 unchanged lines
2063 2223
 "errno",
2064 2224
 "libc",
2065 2225
 "linux-raw-sys",
2066 -
 "windows-sys 0.61.2",
2226 +
 "windows-sys 0.59.0",
2067 2227
]
2068 2228
2069 2229
[[package]]
…
395 unchanged lines
2465 2625
 "hyper-util",
2466 2626
 "oxy-core",
2467 2627
 "rand 0.8.8",
2628 +
 "rcgen",
2629 +
 "ring",
2630 +
 "rustls",
2631 +
 "rustls-pemfile",
2468 2632
 "serde",
2469 2633
 "serde_json",
2470 2634
 "spectra-api",
…
1 unchanged lines
2472 2636
 "sqlx",
2473 2637
 "tempfile",
2474 2638
 "tokio",
2639 +
 "tokio-rustls",
2475 2640
 "tokio-stream",
2476 2641
 "tracing",
2642 +
 "x509-parser",
2477 2643
]
2478 2644
2479 2645
[[package]]
…
50 unchanged lines
2530 2696
source = "registry+https://github.com/rust-lang/crates.io-index"
2531 2697
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
2532 2698
dependencies = [
2533 -
 "base64",
2699 +
 "base64 0.22.1",
2534 2700
 "bytes",
2535 2701
 "crc",
2536 2702
 "crossbeam-queue",
…
64 unchanged lines
2601 2767
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
2602 2768
dependencies = [
2603 2769
 "atoi",
2604 -
 "base64",
2770 +
 "base64 0.22.1",
2605 2771
 "bitflags",
2606 2772
 "byteorder",
2607 2773
 "bytes",
…
34 unchanged lines
2642 2808
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
2643 2809
dependencies = [
2644 2810
 "atoi",
2645 -
 "base64",
2811 +
 "base64 0.22.1",
2646 2812
 "bitflags",
2647 2813
 "byteorder",
2648 2814
 "crc",
…
130 unchanged lines
2779 2945
2780 2946
[[package]]
2781 2947
name = "synstructure"
2948 +
version = "0.13.2"
2949 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2950 +
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2951 +
dependencies = [
2952 +
 "proc-macro2",
2953 +
 "quote",
2954 +
 "syn 2.0.119",
2955 +
]
2956 +
2957 +
[[package]]
2958 +
name = "synstructure"
2782 2959
version = "0.14.0"
2783 2960
source = "registry+https://github.com/rust-lang/crates.io-index"
2784 2961
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
…
10 unchanged lines
2795 2972
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2796 2973
dependencies = [
2797 2974
 "fastrand",
2798 -
 "getrandom 0.4.3",
2975 +
 "getrandom 0.3.4",
2799 2976
 "once_cell",
2800 2977
 "rustix",
2801 -
 "windows-sys 0.61.2",
2978 +
 "windows-sys 0.59.0",
2802 2979
]
2803 2980
2804 2981
[[package]]
…
52 unchanged lines
2857 3034
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
2858 3035
dependencies = [
2859 3036
 "cfg-if",
3037 +
]
3038 +
3039 +
[[package]]
3040 +
name = "time"
3041 +
version = "0.3.55"
3042 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3043 +
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3044 +
dependencies = [
3045 +
 "deranged",
3046 +
 "num-conv",
3047 +
 "powerfmt",
3048 +
 "serde_core",
3049 +
 "time-core",
3050 +
 "time-macros",
3051 +
]
3052 +
3053 +
[[package]]
3054 +
name = "time-core"
3055 +
version = "0.1.9"
3056 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3057 +
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3058 +
3059 +
[[package]]
3060 +
name = "time-macros"
3061 +
version = "0.2.32"
3062 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3063 +
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
3064 +
dependencies = [
3065 +
 "num-conv",
3066 +
 "time-core",
2860 3067
]
2861 3068
2862 3069
[[package]]
…
40 unchanged lines
2903 3110
]
2904 3111
2905 3112
[[package]]
3113 +
name = "tokio-rustls"
3114 +
version = "0.26.5"
3115 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3116 +
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
3117 +
dependencies = [
3118 +
 "rustls",
3119 +
 "tokio",
3120 +
]
3121 +
3122 +
[[package]]
2906 3123
name = "tokio-stream"
2907 3124
version = "0.1.19"
2908 3125
source = "registry+https://github.com/rust-lang/crates.io-index"
…
218 unchanged lines
3127 3344
source = "registry+https://github.com/rust-lang/crates.io-index"
3128 3345
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
3129 3346
dependencies = [
3130 -
 "base64",
3347 +
 "base64 0.22.1",
3131 3348
 "flate2",
3132 3349
 "log",
3133 3350
 "once_cell",
…
195 unchanged lines
3329 3546
source = "registry+https://github.com/rust-lang/crates.io-index"
3330 3547
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
3331 3548
dependencies = [
3332 -
 "windows-sys 0.61.2",
3549 +
 "windows-sys 0.48.0",
3333 3550
]
3334 3551
3335 3552
[[package]]
…
179 unchanged lines
3515 3732
version = "0.6.4"
3516 3733
source = "registry+https://github.com/rust-lang/crates.io-index"
3517 3734
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
3735 +
3736 +
[[package]]
3737 +
name = "x509-parser"
3738 +
version = "0.18.1"
3739 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3740 +
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
3741 +
dependencies = [
3742 +
 "asn1-rs",
3743 +
 "data-encoding",
3744 +
 "der-parser",
3745 +
 "lazy_static",
3746 +
 "nom",
3747 +
 "oid-registry",
3748 +
 "ring",
3749 +
 "rusticata-macros",
3750 +
 "thiserror 2.0.21",
3751 +
 "time",
3752 +
]
3753 +
3754 +
[[package]]
3755 +
name = "yasna"
3756 +
version = "0.6.0"
3757 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3758 +
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
3759 +
dependencies = [
3760 +
 "bit-vec 0.9.1",
3761 +
 "time",
3762 +
]
3518 3763
3519 3764
[[package]]
3520 3765
name = "yoke"
…
15 unchanged lines
3536 3781
 "proc-macro2",
3537 3782
 "quote",
3538 3783
 "syn 3.0.6",
3539 -
 "synstructure",
3784 +
 "synstructure 0.14.0",
3540 3785
]
3541 3786
3542 3787
[[package]]
…
34 unchanged lines
3577 3822
 "proc-macro2",
3578 3823
 "quote",
3579 3824
 "syn 3.0.6",
3580 -
 "synstructure",
3825 +
 "synstructure 0.14.0",
3581 3826
]
3582 3827
3583 3828
[[package]]
…
77 unchanged lines
modified crates/spectra-core/src/authn.rs +5 −0
…
27 unchanged lines
28 28
    /// The key is bound to another Unix user (logged as `guard.impersonation`).
29 29
    #[error("agent {agent}'s key is bound to uid {bound}, used from uid {from}")]
30 30
    WrongUid { agent: String, bound: u32, from: u32 },
31 +
    /// The key is bound to a Unix user but came over TCP, where there is no
32 +
    /// peer uid to check (spec §8.6; logged as `guard.impersonation`).
33 +
    #[error("agent {agent}'s key is bound to uid {bound} and must come over the Unix socket, not the network")]
34 +
    BoundOverNetwork { agent: String, bound: u32 },
31 35
}
32 36
33 37
/// The outcome: who, and the `auth` for their events.
…
38 unchanged lines
72 76
        if let Principal::Agent { name, uid, learn_uid: learn, .. } = &principal {
73 77
            match (uid, peer_uid) {
74 78
                (Some(bound), Some(from)) if *bound != from => return Err(Refusal::WrongUid { agent: name.clone(), bound: *bound, from }),
79 +
                (Some(bound), None) => return Err(Refusal::BoundOverNetwork { agent: name.clone(), bound: *bound }),
75 80
                (None, Some(from)) if *learn => learn_uid = Some(from),
76 81
                _ => {}
77 82
            }
…
15 unchanged lines
modified crates/spectra-core/src/daemon.rs +9 −0
…
113 unchanged lines
114 114
                let _ = self.ledger.append_one(e).await;
115 115
                tracing::warn!(agent, bound, from, "a key used from the wrong Unix user");
116 116
            }
117 +
            Err(Refusal::BoundOverNetwork { agent, bound }) => {
118 +
                let e = NewEvent::system(
119 +
                    "guard.impersonation",
120 +
                    serde_json::json!({ "agent": agent, "bound_uid": bound, "from_uid": null, "over": "tcp", "method": method }),
121 +
                )
122 +
                .subject(agent.clone());
123 +
                let _ = self.ledger.append_one(e).await;
124 +
                tracing::warn!(agent, bound, "a uid-bound key used over the network");
125 +
            }
117 126
            Ok(Caller { principal: Principal::Agent { name, .. }, learn_uid: Some(uid), .. }) => {
118 127
                let e = NewEvent::system("agent.uid_bound", serde_json::json!({ "name": name, "uid": uid })).subject(name.clone());
119 128
                let _ = self.ledger.append_one(e).await;
…
141 unchanged lines
modified crates/spectra-mcp/Cargo.toml +7 −0
…
22 unchanged lines
23 23
hyper = { version = "1", features = ["client", "http1"] }
24 24
hyper-util = { version = "0.1", features = ["tokio"] }
25 25
http-body-util = "0.1"
26 +
# TLS between machines (spec §8.6), as OxygenHub has it: rustls on ring.
27 +
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
28 +
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
29 +
rustls-pemfile = "2"
30 +
rcgen = "0.14"
31 +
x509-parser = "0.18"
32 +
ring = "0.17"
26 33
27 34
[dev-dependencies]
28 35
tempfile.workspace = true
modified crates/spectra-mcp/src/client.rs +65 −8
…
25 unchanged lines
26 26
    }
27 27
}
28 28
29 +
/// Where the daemon is: its Unix socket, or a server over TLS with a
30 +
/// pinned certificate (spec §8.6).
31 +
#[derive(Clone)]
32 +
pub enum Endpoint {
33 +
    Unix(PathBuf),
34 +
    Tls { addr: String, config: std::sync::Arc<rustls::ClientConfig> },
35 +
}
36 +
37 +
impl Endpoint {
38 +
    /// `https://HOST:PORT` accepting exactly the certificate pinned by `pin`.
39 +
    pub fn tls(url: &str, pin: [u8; 32]) -> Result<Endpoint> {
40 +
        Ok(Endpoint::Tls { addr: host_port(url)?, config: crate::tls::Pinned::new(Some(pin)).client_config()? })
41 +
    }
42 +
43 +
    pub fn describe(&self) -> String {
44 +
        match self {
45 +
            Endpoint::Unix(p) => p.display().to_string(),
46 +
            Endpoint::Tls { addr, .. } => format!("https://{addr}"),
47 +
        }
48 +
    }
49 +
}
50 +
51 +
/// `host:port` of an `https://` URL.
52 +
pub fn host_port(url: &str) -> Result<String> {
53 +
    let rest = url.strip_prefix("https://").with_context(|| format!("{url} is not an https:// URL"))?;
54 +
    let hp = rest.split('/').next().unwrap_or("");
55 +
    if hp.is_empty() {
56 +
        anyhow::bail!("{url} names no host");
57 +
    }
58 +
    // The port defaults to Spectra's.
59 +
    let has_port = if hp.starts_with('[') { hp.contains("]:") } else { hp.contains(':') };
60 +
    Ok(if has_port { hp.to_string() } else { format!("{hp}:7350") })
61 +
}
62 +
63 +
trait Io: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
64 +
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> Io for T {}
65 +
29 66
pub struct Client {
30 -
    socket: PathBuf,
67 +
    endpoint: Endpoint,
31 68
    signer: Option<KeyPair>,
32 69
}
33 70
34 71
impl Client {
35 72
    pub fn new(socket: &Path, signer: Option<KeyPair>) -> Client {
36 -
        Client { socket: socket.to_path_buf(), signer }
73 +
        Client { endpoint: Endpoint::Unix(socket.to_path_buf()), signer }
74 +
    }
75 +
76 +
    pub fn at(endpoint: Endpoint, signer: Option<KeyPair>) -> Client {
77 +
        Client { endpoint, signer }
78 +
    }
79 +
80 +
    pub fn endpoint(&self) -> &Endpoint {
81 +
        &self.endpoint
82 +
    }
83 +
84 +
    async fn io(&self) -> Result<Box<dyn Io>> {
85 +
        Ok(match &self.endpoint {
86 +
            Endpoint::Unix(socket) => Box::new(
87 +
                tokio::net::UnixStream::connect(socket)
88 +
                    .await
89 +
                    .with_context(|| format!("connecting to {} (is `spectra serve` running?)", socket.display()))?,
90 +
            ),
91 +
            Endpoint::Tls { addr, config } => Box::new(crate::tls::connect(addr, config.clone()).await?),
92 +
        })
93 +
    }
94 +
95 +
    /// Whether the daemon answers at the endpoint.
96 +
    pub async fn reachable(&self) -> bool {
97 +
        self.io().await.is_ok()
37 98
    }
38 99
39 100
    /// Whether a daemon answers on the socket.
…
3 unchanged lines
43 104
44 105
    /// Send `method path` with `body` and extra headers, signed.
45 106
    pub async fn send(&self, method: &str, path: &str, headers: &[(&str, String)], body: Vec<u8>) -> Result<Response> {
46 -
        let stream = tokio::net::UnixStream::connect(&self.socket)
47 -
            .await
48 -
            .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?;
107 +
        let stream = self.io().await?;
49 108
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
50 109
        tokio::spawn(async move {
51 110
            let _ = conn.await;
…
39 unchanged lines
91 150
    /// Send a signed GET and hand each line of the streamed body to `line`
92 151
    /// as it arrives (an SSE stream, say); stop when it returns false.
93 152
    pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> {
94 -
        let stream = tokio::net::UnixStream::connect(&self.socket)
95 -
            .await
96 -
            .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?;
153 +
        let stream = self.io().await?;
97 154
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
98 155
        tokio::spawn(async move {
99 156
            let _ = conn.await;
…
54 unchanged lines
modified crates/spectra-mcp/src/http.rs +31 −0
…
39 unchanged lines
40 40
    }
41 41
}
42 42
43 +
impl Connected<axum::serve::IncomingStream<'_, crate::tls::TlsListener>> for PeerInfo {
44 +
    fn connect_info(_: axum::serve::IncomingStream<'_, crate::tls::TlsListener>) -> Self {
45 +
        PeerInfo(None)
46 +
    }
47 +
}
48 +
43 49
/// The app state: the daemon, and the origins a browser may send from.
44 50
#[derive(Clone)]
45 51
pub struct AppState {
…
8 unchanged lines
54 60
    Router::new()
55 61
        .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed))
56 62
        .route("/api/v1/ledger/events", post(owner_events))
63 +
        .route("/api/v1/instance", axum::routing::get(instance))
57 64
        .with_state(state)
65 +
}
66 +
67 +
/// `GET /api/v1/instance`, unauthenticated (spec §8.6): who this Spectra
68 +
/// is, whose it is, and the instance key's word for its TLS certificate, so
69 +
/// a client can decide whether to trust it before it signs anything.
70 +
async fn instance(State(state): State<AppState>) -> Response {
71 +
    let (instance, owner) = {
72 +
        let p = state.daemon.principals.read().await;
73 +
        (p.instance().cloned(), p.owner().map(|o| o.hash().to_hex()))
74 +
    };
75 +
    let Some(instance) = instance else {
76 +
        return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response();
77 +
    };
78 +
    let tls = match crate::instance::latest_tls(&state.daemon.ledger).await {
79 +
        Ok(Some(e)) => json!({ "payload": e.json(), "proof": e.auth.proof.as_ref().map(hex::encode) }),
80 +
        _ => Json::Null,
81 +
    };
82 +
    axum::Json(json!({
83 +
        "name": instance.name,
84 +
        "instance": spectra_core::principals::identity_payload(&instance),
85 +
        "owner": owner,
86 +
        "tls": tls,
87 +
    }))
88 +
    .into_response()
58 89
}
59 90
60 91
async fn not_allowed() -> StatusCode {
…
146 unchanged lines
modified crates/spectra-mcp/src/lib.rs +2 −0
…
4 unchanged lines
5 5
pub mod api;
6 6
pub mod client;
7 7
pub mod http;
8 +
pub mod instance;
8 9
pub mod protocol;
9 10
pub mod session;
11 +
pub mod tls;
10 12
pub mod tools;
11 13
12 14
/// The MCP revisions Tuner speaks, newest first (spec §15.1).
…
9 unchanged lines
modified docs/plan.md +1 −1
…
29 unchanged lines
30 30
31 31
## Sprint 2: client and server
32 32
33 -
- [ ] #20 Server over TLS: a certificate signed by the instance key, /api/v1/instance, no uid over TCP.
33 +
- [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs.
34 34
- [ ] #22 Remote setup: spectra init --owner KEYID and the owner's claim from the client.
35 35
- [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network.
36 36
- [ ] #23 The CLI without a ledger file: every command through the JSON API.
…
20 unchanged lines
modified docs/spec.md +1 −1
…
337 unchanged lines
338 338
339 339
Spectra may run on one machine and be used from others: the daemon on a server, and the owner, the CLI, the shims and the agents on a workstation. Nothing about identity changes. Every request is still signed where the key lives (§8.5); keys never leave the machine they were made on; the server holds only its instance key.
340 340
341 -
**The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key signs the certificate's binding, `"spectra1 tls " || SHA-256(SubjectPublicKeyInfo)`, through the challenge signing of §8.5, and the daemon records it as an `instance.tls` event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, the certificate's SubjectPublicKeyInfo hash and the binding signature.
341 +
**The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key vouches for the certificate with an `instance.tls` event, signed over its payload as CLI events are (§9.5), naming the SHA-256 of the certificate's SubjectPublicKeyInfo and its names; the daemon appends one whenever the certificate differs from the last recorded, and `spectra admin verify` checks it like any signed event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, and the latest `instance.tls` payload with its proof.
342 342
343 343
**Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity (their `OXY_HOME`) delegates to that instance key now. Until the owner has delegated, it saves the instance's public identity and prints the `oxy identity delegate add` that would; it never asks the owner to trust a fingerprint by eye. On success it writes the client configuration, `$XDG_CONFIG_HOME/spectra/client.toml`:
344 344
…
683 unchanged lines
modified src/cli/mod.rs +11 −5
…
56 unchanged lines
57 57
    },
58 58
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
59 59
    Serve {
60 -
        /// The loopback address and port for browsers and HTTP clients
61 -
        #[arg(long, default_value = "127.0.0.1:7350")]
62 -
        bind: String,
60 +
        /// The address and port for browsers and HTTP clients (default:
61 +
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
62 +
        /// machines connect over TLS (spec §8.6)
63 +
        #[arg(long)]
64 +
        bind: Option<String>,
65 +
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
66 +
        /// (default: `[server] tls`, else auto)
67 +
        #[arg(long)]
68 +
        tls: Option<String>,
63 69
        /// Allow a non-loopback address in plain HTTP
64 70
        #[arg(long)]
65 71
        insecure: bool,
…
290 unchanged lines
356 362
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
357 363
            }
358 364
        }),
359 -
        Command::Serve { bind, insecure, open } => {
365 +
        Command::Serve { bind, tls, insecure, open } => {
360 366
            init_logging();
361 -
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open))
367 +
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
362 368
        }
363 369
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
364 370
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
…
49 unchanged lines
modified src/cli/serve.rs +115 −9
1 1
//! `spectra serve`: the daemon (spec §7.1, §21.1). It holds the ledger,
2 -
//! serves Tuner (and, as they land, the API and Radar) on the Unix socket
3 -
//! and on a loopback port, and writes a checkpoint when it stops.
2 +
//! serves Tuner, the API and Radar on the Unix socket and on a TCP port,
3 +
//! over TLS beyond loopback (§8.6), and writes a checkpoint when it stops.
4 4
5 5
use super::{ui, Ctx};
6 6
use anyhow::{bail, Context, Result};
…
3 unchanged lines
10 10
use std::net::SocketAddr;
11 11
use std::sync::Arc;
12 12
13 -
pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool, open: bool) -> Result<i32> {
14 -
    let addr: SocketAddr = bind.parse().with_context(|| format!("--bind {bind} is not an address and port"))?;
15 -
    if !addr.ip().is_loopback() && !insecure {
16 -
        bail!("Spectra serves loopback only unless it has TLS (spec §21.1); pass --insecure to bind {addr} in plain HTTP anyway");
13 +
/// `[server]` of spectra.toml, with the command line's overrides.
14 +
struct ServerCfg {
15 +
    bind: String,
16 +
    tls: String,
17 +
    files: Option<(std::path::PathBuf, std::path::PathBuf)>,
18 +
    names: Vec<String>,
19 +
}
20 +
21 +
fn server_cfg(ctx: &Ctx, bind: Option<String>, tls: Option<String>) -> Result<ServerCfg> {
22 +
    let text = std::fs::read_to_string(ctx.data.join("spectra.toml")).unwrap_or_default();
23 +
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", ctx.data.join("spectra.toml").display()))?;
24 +
    let server = t.get("server").and_then(|s| s.as_table()).cloned().unwrap_or_default();
25 +
    let (mut mode, mut files) = (String::from("auto"), None);
26 +
    match server.get("tls") {
27 +
        Some(toml::Value::String(m)) => mode = m.clone(),
28 +
        Some(toml::Value::Table(f)) => {
29 +
            let path = |k: &str| f.get(k).and_then(|v| v.as_str()).map(std::path::PathBuf::from);
30 +
            files = Some((path("cert").context("[server] tls needs cert")?, path("key").context("[server] tls needs key")?));
31 +
            mode = "on".into();
32 +
        }
33 +
        _ => {}
34 +
    }
35 +
    if let Some(m) = tls {
36 +
        mode = m;
37 +
    }
38 +
    if !matches!(mode.as_str(), "auto" | "on" | "off") {
39 +
        bail!("TLS is auto, on or off, not {mode}");
40 +
    }
41 +
    let names = match server.get("names").and_then(|n| n.as_array()) {
42 +
        Some(a) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
43 +
        None => default_names(),
44 +
    };
45 +
    let bind = bind.or_else(|| server.get("bind").and_then(|b| b.as_str()).map(String::from)).unwrap_or_else(|| "127.0.0.1:7350".into());
46 +
    Ok(ServerCfg { bind, tls: mode, files, names })
47 +
}
48 +
49 +
/// The host name, its fully qualified name, and localhost.
50 +
fn default_names() -> Vec<String> {
51 +
    let mut names = Vec::new();
52 +
    let short = std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
53 +
    let fqdn = std::process::Command::new("hostname")
54 +
        .arg("--fqdn")
55 +
        .output()
56 +
        .ok()
57 +
        .filter(|o| o.status.success())
58 +
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
59 +
        .filter(|h| !h.is_empty());
60 +
    for n in [short, fqdn, Some("localhost".to_string())].into_iter().flatten() {
61 +
        if !names.contains(&n) {
62 +
            names.push(n);
63 +
        }
64 +
    }
65 +
    names
66 +
}
67 +
68 +
type Served = std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send>>;
69 +
70 +
pub async fn serve(ctx: &Ctx, bind: Option<String>, tls: Option<String>, insecure: bool, open: bool) -> Result<i32> {
71 +
    let cfg = server_cfg(ctx, bind, tls)?;
72 +
    let addr: SocketAddr = cfg.bind.parse().with_context(|| format!("{} is not an address and port", cfg.bind))?;
73 +
    let use_tls = match cfg.tls.as_str() {
74 +
        "on" => true,
75 +
        "off" => false,
76 +
        _ => !addr.ip().is_loopback(),
77 +
    };
78 +
    if !addr.ip().is_loopback() && !use_tls && !insecure {
79 +
        bail!("Spectra serves other machines over TLS only (spec §21.1); drop --tls off, or pass --insecure to bind {addr} in plain HTTP anyway");
17 80
    }
18 81
    let socket = ctx.socket();
19 82
    if socket.exists() {
…
11 unchanged lines
31 94
    }
32 95
    let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?;
33 96
    let port = tcp.local_addr()?.port();
34 -
    let origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
97 +
    let mut origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
98 +
    let tls = if use_tls {
99 +
        let source = match &cfg.files {
100 +
            Some((cert, key)) => spectra_mcp::tls::CertSource::Files { cert: cert.clone(), key: key.clone() },
101 +
            None => spectra_mcp::tls::CertSource::Own { names: cfg.names.clone() },
102 +
        };
103 +
        let t = spectra_mcp::tls::ServerTls::load(&ctx.data, &source)?;
104 +
        let instance = spectra_core::keys::KeyPair::instance(&ctx.data, "spectra")?;
105 +
        spectra_mcp::instance::record_tls(&daemon.ledger, &instance, &t.spki_sha256, &t.names).await?;
106 +
        for n in &cfg.names {
107 +
            origins.push(format!("https://{n}:{port}"));
108 +
        }
109 +
        origins.push(format!("https://{addr}"));
110 +
        Some(t)
111 +
    } else {
112 +
        None
113 +
    };
35 114
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins), sessions: Default::default() };
36 115
    let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::router(state.clone())).merge(radar::router(state));
37 116
…
4 unchanged lines
42 121
        .unwrap_or(0);
43 122
    println!("{} {}", ui::done("Serving"), ui::subject("Spectra"));
44 123
    println!("{}", ui::kv("socket", ui::path(socket.display())));
45 -
    println!("{}", ui::kv("http", format!("http://127.0.0.1:{port}")));
124 +
    match &tls {
125 +
        Some(t) => {
126 +
            let host = if addr.ip().is_unspecified() {
127 +
                cfg.names.first().cloned().unwrap_or_else(|| "localhost".into())
128 +
            } else {
129 +
                addr.ip().to_string()
130 +
            };
131 +
            println!("{}", ui::kv("https", format!("https://{host}:{port}")));
132 +
            println!(
133 +
                "{}",
134 +
                ui::kv(
135 +
                    "certificate",
136 +
                    format!("{}{}", ui::hash(hex::encode(&t.spki_sha256[..8])), ui::dim(" (signed by the instance key)"))
137 +
                )
138 +
            );
139 +
        }
140 +
        None => println!("{}", ui::kv("http", format!("http://{}:{port}", addr.ip()))),
141 +
    }
46 142
    println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep())));
47 143
    println!("  {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME"));
48 144
    println!("  {} {}", ui::note(), ui::dim("open Radar with: spectra open"));
…
14 unchanged lines
63 159
    };
64 160
    let unix_srv =
65 161
        axum::serve(unix, app.clone().into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx.clone()));
66 -
    let tcp_srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
162 +
    let tcp_srv: Served = match &tls {
163 +
        Some(t) => {
164 +
            let listener = spectra_mcp::tls::TlsListener::new(tcp, t.config.clone())?;
165 +
            let srv = axum::serve(listener, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
166 +
            Box::pin(async move { srv.await })
167 +
        }
168 +
        None => {
169 +
            let srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
170 +
            Box::pin(async move { srv.await })
171 +
        }
172 +
    };
67 173
    let signal = async move {
68 174
        shutdown_signal().await;
69 175
        let _ = stop_tx.send(true);
…
39 unchanged lines
modified tests/common/mod.rs +19 −0
…
73 unchanged lines
74 74
        child.wait().ok();
75 75
        panic!("spectra serve did not start");
76 76
    }
77 +
    /// Serve with TLS on a loopback port of its own; returns the port.
78 +
    pub fn start_tls(d: &Path) -> (Serving, u16) {
79 +
        use std::io::BufRead;
80 +
        let mut child =
81 +
            hermetic(d).args(["serve", "--bind", "127.0.0.1:0", "--tls", "on"]).stdout(std::process::Stdio::piped()).spawn().unwrap();
82 +
        let out = child.stdout.take().unwrap();
83 +
        let (tx, rx) = std::sync::mpsc::channel();
84 +
        std::thread::spawn(move || {
85 +
            for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {
86 +
                if let Some(url) = line.split_whitespace().find(|w| w.starts_with("https://")) {
87 +
                    let port: u16 = url.rsplit(':').next().unwrap().parse().unwrap();
88 +
                    let _ = tx.send(port);
89 +
                }
90 +
            }
91 +
        });
92 +
        let port = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("spectra serve printed its https address");
93 +
        (Serving { child, socket: d.join("data/spectra.sock") }, port)
94 +
    }
95 +
77 96
    pub fn stop(mut self) {
78 97
        unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
79 98
        let status = self.child.wait().unwrap();
…
14 unchanged lines