OXYGENHUB

Coverage / src/cli/serve.rs 79.3%

142 of 179 lines run, 18 of 66 functions · run #20 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
2118×
fn server_cfg(ctx: &Ctx, bind: Option<String>, tls: Option<String>) -> Result<ServerCfg> {
2218×
    let text = std::fs::read_to_string(ctx.data.join("spectra.toml")).unwrap_or_default();
2318×
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", ctx.data.join("spectra.toml").display()))?;
2418×
    let server = t.get("server").and_then(|s| s.as_table()).cloned().unwrap_or_default();
2518×
    let (mut mode, mut files) = (String::from("auto"), None);
2618×
    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
        }
3318×
        _ => {}
34
    }
3518×
    if let Some(m) = tls {
368×
        mode = m;
3710×
    }
3818×
    if !matches!(mode.as_str(), "auto" | "on" | "off") {
390×
        bail!("TLS is auto, on or off, not {mode}");
4018×
    }
4118×
    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(),
4318×
        None => default_names(),
44
    };
4518×
    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());
4618×
    Ok(ServerCfg { bind, tls: mode, files, names })
4718×
}
48
49
/// The host name, its fully qualified name, and localhost.
5018×
fn default_names() -> Vec<String> {
5118×
    let mut names = Vec::new();
5218×
    let short = std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
5318×
    let fqdn = std::process::Command::new("hostname")
5418×
        .arg("--fqdn")
5518×
        .output()
5618×
        .ok()
5718×
        .filter(|o| o.status.success())
5818×
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5918×
        .filter(|h| !h.is_empty());
6018×
    for n in [short, fqdn, Some("localhost".to_string())].into_iter().flatten() {
6118×
        if !names.contains(&n) {
6218×
            names.push(n);
6318×
        }
64
    }
6518×
    names
6618×
}
67
68
type Served = std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send>>;
69
7018×
pub async fn serve(ctx: &Ctx, bind: Option<String>, tls: Option<String>, insecure: bool, open: bool) -> Result<i32> {
7118×
    let cfg = server_cfg(ctx, bind, tls)?;
7218×
    let addr: SocketAddr = cfg.bind.parse().with_context(|| format!("{} is not an address and port", cfg.bind))?;
7318×
    let use_tls = match cfg.tls.as_str() {
7418×
        "on" => true,
7511×
        "off" => false,
7610×
        _ => !addr.ip().is_loopback(),
77
    };
7818×
    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");
8017×
    }
8117×
    let socket = ctx.socket();
8217×
    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();
8717×
    }
8817×
    let daemon = Daemon::start(&ctx.data).await?;
8917×
    let unix = tokio::net::UnixListener::bind(&socket).with_context(|| format!("binding {}", socket.display()))?;
90
    #[cfg(unix)]
91
    {
92
        use std::os::unix::fs::PermissionsExt;
9317×
        std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o660))?;
94
    }
9517×
    let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?;
9617×
    let port = tcp.local_addr()?.port();
9717×
    let mut origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
9817×
    let tls = if use_tls {
997×
        let source = match &cfg.files {
1000×
            Some((cert, key)) => spectra_mcp::tls::CertSource::Files { cert: cert.clone(), key: key.clone() },
1017×
            None => spectra_mcp::tls::CertSource::Own { names: cfg.names.clone() },
102
        };
1037×
        let t = spectra_mcp::tls::ServerTls::load(&ctx.data, &source)?;
1047×
        let instance = spectra_core::keys::KeyPair::instance(&ctx.data, "spectra")?;
1057×
        spectra_mcp::instance::record_tls(&daemon.ledger, &instance, &t.spki_sha256, &t.names).await?;
1067×
        for n in &cfg.names {
1077×
            origins.push(format!("https://{n}:{port}"));
1087×
        }
1097×
        origins.push(format!("https://{addr}"));
1107×
        Some(t)
111
    } else {
11210×
        None
113
    };
11417×
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins), sessions: Default::default() };
11517×
    let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::router(state.clone())).merge(radar::router(state));
116
11717×
    let (head, _) = daemon.ledger.head().await;
11817×
    let agents = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agents WHERE disabled_ms IS NULL")
11917×
        .fetch_one(daemon.ledger.pool())
12017×
        .await
12117×
        .unwrap_or(0);
12217×
    println!("{} {}", ui::done("Serving"), ui::subject("Spectra"));
12317×
    println!("{}", ui::kv("socket", ui::path(socket.display())));
12417×
    match &tls {
1257×
        Some(t) => {
1267×
            let host = if addr.ip().is_unspecified() {
1270×
                cfg.names.first().cloned().unwrap_or_else(|| "localhost".into())
128
            } else {
1297×
                addr.ip().to_string()
130
            };
1317×
            println!("{}", ui::kv("https", format!("https://{host}:{port}")));
1327×
            println!(
133
                "{}",
1347×
                ui::kv(
1357×
                    "certificate",
1367×
                    format!("{}{}", ui::hash(hex::encode(&t.spki_sha256[..8])), ui::dim(" (signed by the instance key)"))
137
                )
138
            );
139
        }
14010×
        None => println!("{}", ui::kv("http", format!("http://{}:{port}", addr.ip()))),
141
    }
14217×
    println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep())));
14317×
    println!("  {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME"));
14417×
    println!("  {} {}", ui::note(), ui::dim("open Radar with: spectra open"));
14517×
    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).await {
1510×
                eprintln!("{} {e:#}", ui::caution());
1520×
            }
1530×
        });
15417×
    }
155
15617×
    let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
15734×
    let stop = |mut rx: tokio::sync::watch::Receiver<bool>| async move {
15834×
        let _ = rx.wait_for(|s| *s).await;
15968×
    };
16017×
    let unix_srv =
16117×
        axum::serve(unix, app.clone().into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx.clone()));
16217×
    let tcp_srv: Served = match &tls {
1637×
        Some(t) => {
1647×
            let listener = spectra_mcp::tls::TlsListener::new(tcp, t.config.clone())?;
1657×
            let srv = axum::serve(listener, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
1667×
            Box::pin(async move { srv.await })
167
        }
168
        None => {
16910×
            let srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx));
17010×
            Box::pin(async move { srv.await })
171
        }
172
    };
17317×
    let signal = async move {
17417×
        shutdown_signal().await;
17517×
        let _ = stop_tx.send(true);
17617×
    };
177
    // After the signal, open connections get five seconds to finish: a
178
    // `subscriptions/listen` stream never does by itself.
17917×
    let (stopping_tx, mut stopping) = tokio::sync::watch::channel(false);
18017×
    tokio::spawn(async move {
18117×
        signal.await;
18217×
        let _ = stopping_tx.send(true);
18317×
    });
18417×
    let servers = async { tokio::join!(unix_srv, tcp_srv) };
18517×
    tokio::select! {
18617×
        (u, t) = servers => {
18717×
            u?;
18817×
            t?;
189
        }
19017×
        _ = async {
19117×
            let _ = stopping.wait_for(|s| *s).await;
19214×
            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
    }
19717×
    daemon.shutdown().await;
19817×
    std::fs::remove_file(&socket).ok();
19917×
    println!("{} {}", ui::done("Stopped"), ui::dim(format!("ledger checkpointed at {} events", daemon.ledger.head().await.0)));
20017×
    Ok(0)
20118×
}
202
20317×
async fn shutdown_signal() {
204
    #[cfg(unix)]
205
    {
20617×
        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("a SIGTERM handler");
20717×
        tokio::select! {
20817×
            _ = tokio::signal::ctrl_c() => {}
20917×
            _ = term.recv() => {}
210
        }
211
    }
212
    #[cfg(not(unix))]
213
    let _ = tokio::signal::ctrl_c().await;
21417×
}