//! v3.4: persist the server's *actually*-bound transport endpoints to a side file next to //! `server.toml`, so a later operator action (`aura sign-bridges --from-runtime …`) can re-sign //! the bridges manifest with the right per-transport ports without the operator having to grep //! the server logs. //! //! The runtime file is JSON, named `.runtime.json`, and it is NOT signed — it is a //! local-state artefact that lives only on the server box. The bridges manifest the operator //! produces from it IS signed (with the CA key, exactly like a hand-authored manifest). //! //! ## Rationale //! //! The previous (v3.3) flow assumed the operator's `[transport]` ports in `server.toml` were the //! truth and clients learned them from the matching `client.toml`. In practice port 443 is heavily //! contested (sing-box, Hysteria2, reverse proxies), and a busy port silently lost the bind on the //! v3.3 server. v3.4 scans forward at bind time (see [`aura_transport::MultiServer::bind_with_outer_or_scan`]) //! — and to keep clients in sync, the operator must be able to mint a bridges manifest reflecting //! the chosen ports. This module is the in-between: the bind writes the runtime file, the operator //! reads it back at signing time. //! //! ## Format //! //! ```json //! { //! "version": 1, //! "bound_at_unix": 1717000000, //! "endpoints": { //! "udp": "0.0.0.0:8443", //! "tcp": "0.0.0.0:8443", //! "quic": "0.0.0.0:8444" //! } //! } //! ``` //! //! Missing keys mean "this transport was not bound" (either disabled in config or the scan failed //! to find a free port within the budget). use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; use aura_transport::Endpoints; use serde::{Deserialize, Serialize}; /// On-disk schema for the runtime endpoint snapshot. Single source of truth for `aura sign-bridges /// --from-runtime` to read back what the server actually bound. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeEndpoints { /// Schema version. Currently `1`. pub version: u8, /// Unix seconds at which the server wrote this snapshot. Useful for "is this stale?". pub bound_at_unix: u64, /// Per-transport bound `SocketAddr`s. Absent keys = transport disabled or bind failed. pub endpoints: BoundEndpoints, } /// String-formatted bound endpoints. Strings (not `SocketAddr`s directly) so the JSON is readable /// by a human grepping the file. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BoundEndpoints { #[serde(default, skip_serializing_if = "Option::is_none")] pub udp: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tcp: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub quic: Option, } impl From<&Endpoints> for BoundEndpoints { fn from(eps: &Endpoints) -> Self { Self { udp: eps.udp.map(|s| s.to_string()), tcp: eps.tcp.map(|s| s.to_string()), quic: eps.quic.map(|s| s.to_string()), } } } /// Derive the runtime-file path from a `server.toml` path. `/etc/aura/server.toml` ⇒ /// `/etc/aura/server.toml.runtime.json`. We append rather than replace the extension so an /// operator listing the directory sees the two files side by side under sort order. #[must_use] pub fn runtime_path_for(server_toml: &Path) -> PathBuf { let mut s = server_toml.as_os_str().to_owned(); s.push(".runtime.json"); PathBuf::from(s) } /// Persist `bound` to the runtime file alongside `server_toml`. Creates parent directories if /// needed; overwrites any existing snapshot. pub fn write_runtime_endpoints(server_toml: &Path, bound: &Endpoints) -> anyhow::Result<()> { let path = runtime_path_for(server_toml); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let snap = RuntimeEndpoints { version: 1, bound_at_unix: now, endpoints: BoundEndpoints::from(bound), }; let json = serde_json::to_string_pretty(&snap).context("serialising runtime endpoints to JSON")?; if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("creating runtime-state dir {}", parent.display()))?; } } fs::write(&path, json) .with_context(|| format!("writing runtime endpoints to {}", path.display()))?; Ok(()) } /// Read back what `write_runtime_endpoints` wrote. Returns `Ok(None)` if the file is missing /// (treat as "operator hasn't bound recently" — fall back to `server.toml` values). pub fn read_runtime_endpoints(server_toml: &Path) -> anyhow::Result> { let path = runtime_path_for(server_toml); match fs::read_to_string(&path) { Ok(text) => { let snap: RuntimeEndpoints = serde_json::from_str(&text) .with_context(|| format!("parsing runtime endpoints JSON at {}", path.display()))?; Ok(Some(snap)) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(anyhow::anyhow!( "reading runtime endpoints file {}: {e}", path.display() )), } } /// Extract the bound `SocketAddr` for each transport from a [`RuntimeEndpoints`]. Useful for the /// operator's `aura sign-bridges --from-runtime` path: parse the strings back into `SocketAddr`s /// and convert into [`crate::bridges::BridgeEndpoint`]s. pub fn parse_runtime_addrs(snap: &RuntimeEndpoints) -> anyhow::Result { fn parse_one(s: &Option, label: &str) -> anyhow::Result> { match s { Some(raw) => { let parsed: SocketAddr = raw .parse() .with_context(|| format!("parsing runtime endpoint {label} = '{raw}'"))?; Ok(Some(parsed)) } None => Ok(None), } } Ok(Endpoints { udp: parse_one(&snap.endpoints.udp, "udp")?, tcp: parse_one(&snap.endpoints.tcp, "tcp")?, quic: parse_one(&snap.endpoints.quic, "quic")?, }) } #[cfg(test)] mod tests { use super::*; #[test] fn runtime_path_appends_suffix() { let p = runtime_path_for(Path::new("/etc/aura/server.toml")); assert_eq!(p, PathBuf::from("/etc/aura/server.toml.runtime.json")); } #[test] fn write_then_read_round_trip() { let tmp = std::env::temp_dir().join(format!("aura-runtime-state-{}.toml", std::process::id())); let eps = Endpoints { udp: Some("0.0.0.0:9443".parse().unwrap()), tcp: Some("0.0.0.0:9443".parse().unwrap()), quic: Some("0.0.0.0:9444".parse().unwrap()), }; write_runtime_endpoints(&tmp, &eps).expect("write"); let read = read_runtime_endpoints(&tmp) .expect("read") .expect("present"); assert_eq!(read.version, 1); let parsed = parse_runtime_addrs(&read).expect("parse"); assert_eq!(parsed.udp.unwrap().port(), 9443); assert_eq!(parsed.quic.unwrap().port(), 9444); let _ = fs::remove_file(runtime_path_for(&tmp)); } #[test] fn missing_runtime_file_returns_none() { let tmp = std::env::temp_dir().join(format!("aura-no-runtime-{}.toml", std::process::id())); let _ = fs::remove_file(runtime_path_for(&tmp)); let read = read_runtime_endpoints(&tmp).expect("ok"); assert!(read.is_none()); } }