OXYGENHUB

Coverage / src/cli/serve.rs 79.3%

142 of 179 lines run, 18 of 66 functions · run #30 on main · run missed

1
//! `spectra serve`: the daemon (spec §7.1, §21.1). It holds the ledger,
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
5
use super::{ui, Ctx};
6
use anyhow::{bail, Context, Result};
7
use spectra_core::daemon::Daemon;
8
use spectra_mcp::client::Client;
9
use spectra_mcp::http::{AppState, PeerInfo};
10
use std::net::SocketAddr;
11
use std::sync::Arc;
12
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
2122×
fn server_cfg(ctx: &Ctx, bind: Option<String>, tls: Option<String>) -> Result<ServerCfg> {
2222×
    let text = std::fs::read_to_string(ctx.data.join("spectra.toml")).unwrap_or_default();
2322×
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", ctx.data.join("spectra.toml").display()))?;
2422×
    let server = t.get("server").and_then(|s| s.as_table()).cloned().unwrap_or_default();
2522×
    let (mut mode, mut files) = (String::from("auto"), None);
2622×
    match server.get("tls") {
270×
        Some(toml::Value::String(m)) => mode = m.clone(),
280×
        Some(toml::Value::Table(f)) => {
290×
            let path = |k: &str| f.get(k).and_then(|v| v.as_str()).map(std::path::PathBuf::from);
300×
            files = Some((path("cert").context("[server] tls needs cert")?, path("key").context("[server] tls needs key")?));
310×
            mode = "on".into();
32
        }
3322×
        _ => {}
34
    }
3522×
    if let Some(m) = tls {
3610×
        mode = m;
3712×
    }
3822×
    if !matches!(mode.as_str(), "auto" | "on" | "off") {
390×
        bail!("TLS is auto, on or off, not {mode}");
4022×
    }
4122×
    let names = match server.get("names").and_then(|n| n.as_array()) {
420×
        Some(a) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
4322×
        None => default_names(),
44
    };
4522×
    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());
4622×
    Ok(ServerCfg { bind, tls: mode, files, names })
4722×
}
48
49
/// The host name, its fully qualified name, and localhost.
5022×
fn default_names() -> Vec<String> {
5122×
    let mut names = Vec::new();
5222×
    let short = std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
5322×
    let fqdn = std::process::Command::new("hostname")
5422×
        .arg("--fqdn")
5522×
        .output()
5622×
        .ok()
5722×
        .filter(|o| o.status.success())
5822×
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5922×
        .filter(|h| !h.is_empty());
6022×
    for n in [short, fqdn, Some("localhost".to_string())].into_iter().flatten() {
6122×
        if !names.contains(&n) {
6222×
            names.push(n);
6322×
        }
64
    }
6522×
    names
6622×
}
67
68
type Served = std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send>>;
69
7022×
pub async fn serve(ctx: &Ctx, bind: Option<String>, tls: Option<String>, insecure: bool, open: bool) -> Result<i32> {
7122×
    let cfg = server_cfg(ctx, bind, tls)?;
7222×
    let addr: SocketAddr = cfg.bind.parse().with_context(|| format!("{} is not an address and port", cfg.bind))?;
7322×
    let use_tls = match cfg.tls.as_str() {
7422×
        "on" => true,
7513×
        "off" => false,
7612×
        _ => !addr.ip().is_loopback(),
77
    };
7822×
    if !addr.ip().is_loopback() && !use_tls && !insecure {
791×
        bail!("Spectra serves other machines over TLS only (spec §21.1); drop --tls off, or pass --insecure to bind {addr} in plain HTTP anyway");
8021×
    }
8121×
    let socket = ctx.socket();
8221×
    if socket.exists() {
830×
        if Client::alive(&socket).await {
840×
            bail!("Spectra is already serving {} (its socket answers)", ctx.data.display());
850×
        }
860×
        std::fs::remove_file(&socket).ok();
8721×
    }
8821×
    let daemon = Daemon::start(&ctx.data).await?;
8921×
    let unix = tokio::net::UnixListener::bind(&socket).with_context(|| format!("binding {}", socket.display()))?;
90
    #[cfg(unix)]
91
    {
92
        use std::os::unix::fs::PermissionsExt;
9321×
        std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o660))?;
94
    }
9521×
    let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?;
9621×
    let port = tcp.local_addr()?.port();
9721×
    let mut origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
9821×
    let tls = if use_tls {
999×
        let source = match &cfg.files {
1000×
            Some((cert, key)) => spectra_mcp::tls::CertSource::Files { cert: cert.clone(), key: key.clone() },
1019×
            None => spectra_mcp::tls::CertSource::Own { names: cfg.names.clone() },
102
        };
1039×
        let t = spectra_mcp::tls::ServerTls::load(&ctx.data, &source)?;
1049×
        let instance = spectra_core::keys::KeyPair::instance(&ctx.data, "spectra")?;
1059×
        spectra_mcp::instance::record_tls(&daemon.ledger, &instance, &t.spki_sha256, &t.names).await?;
1069×
        for n in &cfg.names {
1079×
            origins.push(format!("https://{n}:{port}"));
1089×
        }
1099×
        origins.push(format!("https://{addr}"));
1109×
        Some(t)
111
    } else {
11212×
        None
113
    };
11421×
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins), sessions: Default::default() };
11521×
    let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::router(state.clone())).merge(radar::router(state));
116
11721×
    let (head, _) = daemon.ledger.head().await;
11821×
    let agents = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agents WHERE disabled_ms IS NULL")
11921×
        .fetch_one(daemon.ledger.pool())
12021×
        .await
12121×
        .unwrap_or(0);
12221×
    println!("{} {}", ui::done("Serving"), ui::subject("Spectra"));
12321×
    println!("{}", ui::kv("socket", ui::path(socket.display())));
12421×
    match &tls {
1259×
        Some(t) => {
1269×
            let host = if addr.ip().is_unspecified() {
1270×
                cfg.names.first().cloned().unwrap_or_else(|| "localhost".into())
128
            } else {
1299×
                addr.ip().to_string()
130
            };
1319×
            println!("{}", ui::kv("https", format!("https://{host}:{port}")));
1329×
            println!(
133
                "{}",
1349×
                ui::kv(
1359×
                    "certificate",
1369×
                    format!("{}{}", ui::hash(hex::encode(&t.spki_sha256[..8])), ui::dim(" (signed by the instance key)"))
137
                )
138
            );
139
        }
14012×
        None => println!("{}", ui::kv("http", format!("http://{}:{port}", addr.ip()))),
141
    }
14221×
    println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep())));
14321×
    println!("  {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME"));
14421×
    println!("  {} {}", ui::note(), ui::dim("open Radar with: spectra open"));
14521×
    if open {
1460×
        let ctx2 = Ctx { data: ctx.data.clone() };
1470×
        tokio::spawn(async move {
148
            // Once the socket answers, sign the link as the owner and open it.
1490×
            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1500×
            if let Err(e) = super::open::open(&ctx2, None, true, None).await {
1510×
                eprintln!("{} {e:#}", ui::caution());
1520×
            }
1530×
        });
15421×
    }
155
15621×
    let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
15742×
    let stop = |mut rx: tokio::sync::watch::Receiver<bool>| async move {
15842×
        let _ = rx.wait_for(|s| *s).await;
15984×
    };
16021×
    let unix_srv =
16121×
        axum::serve(unix, app.clone().into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx.clone()));
16221×
    let tcp_srv: Served = match &tls {
1639×
        Some(t) => {
1649×
            let listener = spectra_mcp::tls::TlsListener::new(tcp, t.config.clone())?;
1659×
            let srv = axum::serve(listener, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
1669×
            Box::pin(async move { srv.await })
167
        }
168
        None => {
16912×
            let srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
17012×
            Box::pin(async move { srv.await })
171
        }
172
    };
17321×
    let signal = async move {
17421×
        shutdown_signal().await;
17521×
        let _ = stop_tx.send(true);
17621×
    };
177
    // After the signal, open connections get five seconds to finish: a
178
    // `subscriptions/listen` stream never does by itself.
17921×
    let (stopping_tx, mut stopping) = tokio::sync::watch::channel(false);
18021×
    tokio::spawn(async move {
18121×
        signal.await;
18221×
        let _ = stopping_tx.send(true);
18321×
    });
18421×
    let servers = async { tokio::join!(unix_srv, tcp_srv) };
18521×
    tokio::select! {
18621×
        (u, t) = servers => {
18721×
            u?;
18821×
            t?;
189
        }
19021×
        _ = async {
19121×
            let _ = stopping.wait_for(|s| *s).await;
19212×
            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1930×
        } => {
1940×
            tracing::info!("connections still open five seconds after the signal; stopping anyway");
195
        }
196
    }
19721×
    daemon.shutdown().await;
19821×
    std::fs::remove_file(&socket).ok();
19921×
    println!("{} {}", ui::done("Stopped"), ui::dim(format!("ledger checkpointed at {} events", daemon.ledger.head().await.0)));
20021×
    Ok(0)
20122×
}
202
20321×
async fn shutdown_signal() {
204
    #[cfg(unix)]
205
    {
20621×
        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("a SIGTERM handler");
20721×
        tokio::select! {
20821×
            _ = tokio::signal::ctrl_c() => {}
20921×
            _ = term.recv() => {}
210
        }
211
    }
212
    #[cfg(not(unix))]
213
    let _ = tokio::signal::ctrl_c().await;
21421×
}