OXYGENHUB

Coverage / src/cli/serve.rs 82.7%

139 of 168 lines run, 18 of 64 functions · run #36 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
2123×
fn server_cfg(ctx: &Ctx, bind: Option<String>, tls: Option<String>) -> Result<ServerCfg> {
2223×
    let text = std::fs::read_to_string(ctx.data.join("spectra.toml")).unwrap_or_default();
2323×
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", ctx.data.join("spectra.toml").display()))?;
2423×
    let server = t.get("server").and_then(|s| s.as_table()).cloned().unwrap_or_default();
2523×
    let (mut mode, mut files) = (String::from("auto"), None);
2623×
    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
        }
3323×
        _ => {}
34
    }
3523×
    if let Some(m) = tls {
369×
        mode = m;
3714×
    }
3823×
    if !matches!(mode.as_str(), "auto" | "on" | "off") {
390×
        bail!("TLS is auto, on or off, not {mode}");
4023×
    }
4123×
    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(),
4323×
        None => default_names(),
44
    };
4523×
    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());
4623×
    Ok(ServerCfg { bind, tls: mode, files, names })
4723×
}
48
49
/// The host name, its fully qualified name, and localhost.
5023×
fn default_names() -> Vec<String> {
5123×
    let mut names = Vec::new();
5223×
    let short = std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
5323×
    let fqdn = std::process::Command::new("hostname")
5423×
        .arg("--fqdn")
5523×
        .output()
5623×
        .ok()
5723×
        .filter(|o| o.status.success())
5823×
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5923×
        .filter(|h| !h.is_empty());
6023×
    for n in [short, fqdn, Some("localhost".to_string())].into_iter().flatten() {
6123×
        if !names.contains(&n) {
6223×
            names.push(n);
6323×
        }
64
    }
6523×
    names
6623×
}
67
68
type Served = std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send>>;
69
7023×
pub async fn serve(ctx: &Ctx, bind: Option<String>, tls: Option<String>, insecure: bool) -> Result<i32> {
7123×
    let cfg = server_cfg(ctx, bind, tls)?;
7223×
    let addr: SocketAddr = cfg.bind.parse().with_context(|| format!("{} is not an address and port", cfg.bind))?;
7323×
    let use_tls = match cfg.tls.as_str() {
7423×
        "on" => true,
7515×
        "off" => false,
7614×
        _ => !addr.ip().is_loopback(),
77
    };
7823×
    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");
8022×
    }
8122×
    let socket = ctx.socket();
8222×
    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();
8722×
    }
8822×
    let daemon = Daemon::start(&ctx.data).await?;
8922×
    let unix = tokio::net::UnixListener::bind(&socket).with_context(|| format!("binding {}", socket.display()))?;
90
    #[cfg(unix)]
91
    {
92
        use std::os::unix::fs::PermissionsExt;
9322×
        std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o660))?;
94
    }
9522×
    let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?;
9622×
    let port = tcp.local_addr()?.port();
9722×
    let mut origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
9822×
    let tls = if use_tls {
998×
        let source = match &cfg.files {
1000×
            Some((cert, key)) => spectra_mcp::tls::CertSource::Files { cert: cert.clone(), key: key.clone() },
1018×
            None => spectra_mcp::tls::CertSource::Own { names: cfg.names.clone() },
102
        };
1038×
        let t = spectra_mcp::tls::ServerTls::load(&ctx.data, &source)?;
1048×
        let instance = spectra_core::keys::KeyPair::instance(&ctx.data, "spectra")?;
1058×
        spectra_mcp::instance::record_tls(&daemon.ledger, &instance, &t.spki_sha256, &t.names).await?;
1068×
        for n in &cfg.names {
1078×
            origins.push(format!("https://{n}:{port}"));
1088×
        }
1098×
        origins.push(format!("https://{addr}"));
1108×
        Some(t)
111
    } else {
11214×
        None
113
    };
11422×
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins) };
11522×
    let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::router(state));
116
11722×
    let (head, _) = daemon.ledger.head().await;
11822×
    let agents = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agents WHERE disabled_ms IS NULL")
11922×
        .fetch_one(daemon.ledger.pool())
12022×
        .await
12122×
        .unwrap_or(0);
12222×
    println!("{} {}", ui::done("Serving"), ui::subject("Spectra"));
12322×
    println!("{}", ui::kv("socket", ui::path(socket.display())));
12422×
    match &tls {
1258×
        Some(t) => {
1268×
            let host = if addr.ip().is_unspecified() {
1270×
                cfg.names.first().cloned().unwrap_or_else(|| "localhost".into())
128
            } else {
1298×
                addr.ip().to_string()
130
            };
1318×
            println!("{}", ui::kv("https", format!("https://{host}:{port}")));
1328×
            println!(
133
                "{}",
1348×
                ui::kv(
1358×
                    "certificate",
1368×
                    format!("{}{}", ui::hash(hex::encode(&t.spki_sha256[..8])), ui::dim(" (signed by the instance key)"))
137
                )
138
            );
139
        }
14014×
        None => println!("{}", ui::kv("http", format!("http://{}:{port}", addr.ip()))),
141
    }
14222×
    println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep())));
14322×
    println!("  {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME"));
144
14522×
    let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
14644×
    let stop = |mut rx: tokio::sync::watch::Receiver<bool>| async move {
14744×
        let _ = rx.wait_for(|s| *s).await;
14888×
    };
14922×
    let unix_srv =
15022×
        axum::serve(unix, app.clone().into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx.clone()));
15122×
    let tcp_srv: Served = match &tls {
1528×
        Some(t) => {
1538×
            let listener = spectra_mcp::tls::TlsListener::new(tcp, t.config.clone())?;
1548×
            let srv = axum::serve(listener, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
1558×
            Box::pin(async move { srv.await })
156
        }
157
        None => {
15814×
            let srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
15914×
            Box::pin(async move { srv.await })
160
        }
161
    };
16222×
    let signal = async move {
16322×
        shutdown_signal().await;
16422×
        let _ = stop_tx.send(true);
16522×
    };
166
    // After the signal, open connections get five seconds to finish: a
167
    // `subscriptions/listen` stream never does by itself.
16822×
    let (stopping_tx, mut stopping) = tokio::sync::watch::channel(false);
16922×
    tokio::spawn(async move {
17022×
        signal.await;
17122×
        let _ = stopping_tx.send(true);
17222×
    });
17322×
    let servers = async { tokio::join!(unix_srv, tcp_srv) };
17422×
    tokio::select! {
17522×
        (u, t) = servers => {
17622×
            u?;
17722×
            t?;
178
        }
17922×
        _ = async {
18022×
            let _ = stopping.wait_for(|s| *s).await;
18110×
            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1820×
        } => {
1830×
            tracing::info!("connections still open five seconds after the signal; stopping anyway");
184
        }
185
    }
18622×
    daemon.shutdown().await;
18722×
    std::fs::remove_file(&socket).ok();
18822×
    println!("{} {}", ui::done("Stopped"), ui::dim(format!("ledger checkpointed at {} events", daemon.ledger.head().await.0)));
18922×
    Ok(0)
19023×
}
191
19222×
async fn shutdown_signal() {
193
    #[cfg(unix)]
194
    {
19522×
        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("a SIGTERM handler");
19622×
        tokio::select! {
19722×
            _ = tokio::signal::ctrl_c() => {}
19822×
            _ = term.recv() => {}
199
        }
200
    }
201
    #[cfg(not(unix))]
202
    let _ = tokio::signal::ctrl_c().await;
20322×
}