Skip to main content

hatchery_c2/
runtime.rs

1use crate::protocol::{
2    C2ErrorCategory, GapKind, HealthResponse, NodeCursor, NodeFreshness, NodeGap, NodeId,
3    NodeIncarnationId, NodeTransportState, ObservedNode, ProviderAdapterContractSupport,
4    ProviderContractSupport, ReadyResponse, SanitizedError, SlimNodeInventory, StatusResponse,
5    C2_API_VERSION, C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, MAX_C2_GAPS_PER_NODE, MAX_C2_NODES,
6};
7#[cfg(windows)]
8use crate::protocol::MAX_C2_ENDPOINT_BYTES;
9use gate4agent_node_protocol::{NegotiatedNodeCompatibility, NodeEvent, NodeSnapshot};
10use gate4agent_node_wire::read_call_home_announce;
11use std::collections::{BTreeMap, BTreeSet};
12use std::io;
13use std::net::SocketAddr;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16#[cfg(unix)]
17use std::path::{Path, PathBuf};
18#[cfg(unix)]
19use std::os::unix::fs::PermissionsExt;
20use thiserror::Error;
21use tokio::io::{AsyncReadExt, AsyncWriteExt};
22use tokio::net::{TcpListener, TcpStream};
23use tokio::sync::{mpsc, watch, Semaphore};
24use tokio::task::{JoinHandle, JoinSet};
25use tokio::time::{sleep, timeout};
26
27const HEADER_LIMIT_BYTES: usize = 16 * 1024;
28const OPERATOR_BODY_LIMIT_BYTES: usize = 16 * 1024;
29const MAX_HTTP_CONNECTIONS: usize = 16;
30const RESPONSE_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
31
32
33#[cfg(windows)]
34pub const DEFAULT_C2_CONTROL_ENDPOINT: &str = r"\\.\pipe\gate4agent-c2";
35#[cfg(unix)]
36pub const DEFAULT_C2_CONTROL_ENDPOINT: &str = "gate4agent-c2.sock";
37
38#[cfg(windows)]
39pub fn default_c2_control_endpoint() -> Result<String, C2ConfigError> {
40    Ok(DEFAULT_C2_CONTROL_ENDPOINT.to_owned())
41}
42
43#[cfg(unix)]
44pub fn default_c2_control_endpoint() -> Result<String, C2ConfigError> {
45    let root = unix_runtime_root()?;
46    let directory = root.join("gate4agent");
47    let endpoint = directory.join(DEFAULT_C2_CONTROL_ENDPOINT);
48    validate_unix_endpoint(&endpoint).map_err(|_| C2ConfigError::InvalidControlEndpoint)?;
49    Ok(endpoint.to_string_lossy().into_owned())
50}
51
52#[cfg(unix)]
53fn unix_runtime_root() -> Result<PathBuf, C2ConfigError> {
54    if let Some(root) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
55        let root = PathBuf::from(root);
56        if root.is_absolute() { return Ok(root); }
57    }
58    let home = std::env::var_os("HOME").filter(|value| !value.is_empty())
59        .ok_or_else(|| C2ConfigError::RuntimeEndpoint(
60            "neither absolute XDG_RUNTIME_DIR nor HOME is available".to_owned(),
61        ))?;
62    let home = PathBuf::from(home);
63    if !home.is_absolute() {
64        return Err(C2ConfigError::RuntimeEndpoint("HOME is not absolute".to_owned()));
65    }
66    Ok(home.join(".gate4agent").join("run"))
67}
68
69#[derive(Clone)]
70pub struct C2NodeConfig {
71    pub node_id: NodeId,
72    pub endpoint: String,
73    route: C2NodeRoute,
74    token: String,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum C2NodeRoute {
79    Local,
80    SshForwardedLoopback(SocketAddr),
81    /// This relay does not dial the node; the node dials the relay.
82    ///
83    /// For a node the relay has no way to reach -- behind NAT, in a
84    /// container with no published port, on a machine with no listener the
85    /// relay is allowed to open. The protocol roles are unchanged: the
86    /// node is still the server, this relay is still the client asking it
87    /// for things. Only the direction of the TCP connection moves, which
88    /// is why the whole of this variant's implementation is "wait for a
89    /// socket instead of opening one" and then the identical handshake.
90    CallHome,
91}
92
93impl C2NodeConfig {
94    pub fn new(node_id: NodeId, endpoint: impl Into<String>, token: impl Into<String>) -> Result<Self, C2ConfigError> {
95        let endpoint = endpoint.into();
96        let token = token.into();
97        let (endpoint, route) = parse_node_endpoint(&endpoint)
98            .ok_or_else(|| C2ConfigError::InvalidEndpoint(node_id.clone()))?;
99        validate_token(&token)?;
100        Ok(Self { node_id, endpoint, route, token })
101    }
102
103    fn transport_label(&self) -> &'static str {
104        match self.route {
105            C2NodeRoute::Local => local_transport_label(),
106            C2NodeRoute::SshForwardedLoopback(_) => "ssh-forwarded-loopback",
107            C2NodeRoute::CallHome => "call-home",
108        }
109    }
110}
111
112#[derive(Clone, Copy, Debug)]
113pub struct C2Timings {
114    pub poll_interval: Duration,
115    pub fresh_for: Duration,
116    pub attempt_deadline: Duration,
117    pub transient_backoffs: [Duration; 5],
118    pub parked_backoff: Duration,
119    pub http_io_deadline: Duration,
120}
121
122impl Default for C2Timings {
123    fn default() -> Self {
124        Self {
125            poll_interval: Duration::from_millis(250),
126            fresh_for: Duration::from_secs(10),
127            attempt_deadline: Duration::from_secs(5),
128            transient_backoffs: [
129                Duration::from_millis(500), Duration::from_secs(1), Duration::from_secs(2),
130                Duration::from_secs(4), Duration::from_secs(8),
131            ],
132            parked_backoff: Duration::from_secs(30),
133            http_io_deadline: Duration::from_secs(3),
134        }
135    }
136}
137
138#[derive(Clone)]
139pub struct C2Config {
140    pub api_listen: SocketAddr,
141    /// Deprecated (A2): kept for config compatibility. C2 no longer opens an
142    /// operator control socket; core drains and parses g4a.
143    pub control_endpoint: String,
144    /// Deprecated (A2): ignored. Operator control is not accepted on C2.
145    pub control_tunnel_listen: Option<SocketAddr>,
146    api_token: String,
147    pub nodes: Vec<C2NodeConfig>,
148    /// Where nodes that cannot be dialled come to announce themselves.
149    /// `None` unless at least one node is configured `accept`.
150    pub node_listen: Option<SocketAddr>,
151    pub timings: C2Timings,
152    /// Node enrollment (one-time code, neutral status, runtime node add). `None` keeps the
153    /// fixed `--node` set and token authentication only.
154    pub enrollment: Option<crate::enroll_service::EnrollConfig>,
155    /// A2 restreamer: node multiplex uplink listen. Requires `restreamer_drain_listen`.
156    pub restreamer_node_listen: Option<SocketAddr>,
157    /// A2 restreamer: core drain listen (C2 never dials core).
158    pub restreamer_drain_listen: Option<SocketAddr>,
159    /// Bearer core presents on the drain stream. Never logged.
160    restreamer_core_token: Option<String>,
161}
162
163impl C2Config {
164    /// Turn on enrollment. Requires a node-listen address (checked by `validate_call_home`).
165    pub fn with_enrollment(mut self, enrollment: crate::enroll_service::EnrollConfig) -> Self {
166        self.enrollment = Some(enrollment);
167        self
168    }
169
170    pub fn new(api_listen: SocketAddr, api_token: impl Into<String>, nodes: Vec<C2NodeConfig>) -> Result<Self, C2ConfigError> {
171        let api_token = api_token.into();
172        if !api_listen.ip().is_loopback() { return Err(C2ConfigError::NonLoopback(api_listen)); }
173        validate_token(&api_token)?;
174        if nodes.len() > MAX_C2_NODES { return Err(C2ConfigError::NodeCount(nodes.len())); }
175        let mut ids = BTreeSet::new();
176        let mut endpoints = BTreeSet::new();
177        if nodes.iter().any(|node| !ids.insert(node.node_id.clone())) { return Err(C2ConfigError::DuplicateNode); }
178        // Call-home nodes are exempt: they name no endpoint, so every one
179        // of them carries the same `accept` and the uniqueness this check
180        // enforces is `node_id`'s job for them (already done above). The
181        // check exists to stop two dialled nodes pointing at one socket,
182        // which cannot happen to a node that is never dialled.
183        if nodes
184            .iter()
185            .filter(|node| node.route != C2NodeRoute::CallHome)
186            .any(|node| !endpoints.insert(endpoint_key(&node.endpoint)))
187        {
188            return Err(C2ConfigError::DuplicateEndpoint);
189        }
190        let control_endpoint = default_c2_control_endpoint()?;
191        if nodes.iter().any(|node| endpoints_equal(&node.endpoint, &control_endpoint)) {
192            return Err(C2ConfigError::ControlEndpointConflict);
193        }
194        Ok(Self {
195            api_listen,
196            control_endpoint,
197            control_tunnel_listen: None,
198            api_token,
199            nodes,
200            node_listen: None,
201            timings: C2Timings::default(),
202            enrollment: None,
203            restreamer_node_listen: None,
204            restreamer_drain_listen: None,
205            restreamer_core_token: None,
206        })
207    }
208
209    /// Enable the A2 byte restreamer (node uplink + core drain). C2 never dials core.
210    pub fn with_restreamer(
211        mut self,
212        node_listen: SocketAddr,
213        drain_listen: SocketAddr,
214        core_token: impl Into<String>,
215    ) -> Result<Self, C2ConfigError> {
216        let core_token = core_token.into();
217        if core_token.len() < 16 {
218            return Err(C2ConfigError::RestreamerToken);
219        }
220        if node_listen == drain_listen {
221            return Err(C2ConfigError::RestreamerListenConflict);
222        }
223        if node_listen.port() == 0 || node_listen.ip().is_unspecified() {
224            return Err(C2ConfigError::NonLoopbackNodeListen(node_listen));
225        }
226        if drain_listen.port() == 0 || drain_listen.ip().is_unspecified() {
227            return Err(C2ConfigError::InvalidControlTunnel(drain_listen));
228        }
229        self.restreamer_node_listen = Some(node_listen);
230        self.restreamer_drain_listen = Some(drain_listen);
231        self.restreamer_core_token = Some(core_token);
232        Ok(self)
233    }
234
235    /// Production restreamer: both listeners on the C2 WireGuard tunnel IP.
236    pub fn with_restreamer_wg(
237        self,
238        c2_tunnel_ip: std::net::IpAddr,
239        node_port: u16,
240        drain_port: u16,
241        core_token: impl Into<String>,
242    ) -> Result<Self, C2ConfigError> {
243        let bind = crate::restreamer::RestreamerWgBind::production(c2_tunnel_ip, node_port, drain_port)
244            .map_err(|_| C2ConfigError::RestreamerListenConflict)?;
245        self.with_restreamer(bind.node_listen, bind.drain_listen, core_token)
246    }
247
248    /// Binds the address nodes call in on.
249    ///
250    /// Loopback, or one concrete C2 WireGuard tunnel address (same rule as
251    /// [`Self::with_control_tunnel_listen`]). The node wire authenticates both
252    /// ends but encrypts nothing, so `0.0.0.0` and other unspecified binds are
253    /// refused: off-tunnel plaintext would expose terminal contents. When the
254    /// listen address is the C2 tunnel IP, kernel WireGuard encrypts the path
255    /// and either side may dial.
256    pub fn with_node_listen(mut self, node_listen: SocketAddr) -> Result<Self, C2ConfigError> {
257        // Concrete non-unspecified + nonzero port. Loopback stays valid; WG
258        // tunnel addresses (e.g. 10.88.1.2:PORT) are valid for kit peers.
259        if node_listen.port() == 0 || node_listen.ip().is_unspecified() {
260            return Err(C2ConfigError::NonLoopbackNodeListen(node_listen));
261        }
262        if node_listen == self.api_listen {
263            return Err(C2ConfigError::NodeListenConflict);
264        }
265        if self.control_tunnel_listen == Some(node_listen) {
266            return Err(C2ConfigError::NonLoopbackNodeListen(node_listen));
267        }
268        self.node_listen = Some(node_listen);
269        Ok(self)
270    }
271
272    /// Every node that waits to be called needs somewhere to call, so a
273    /// config that asks for one without the other is refused rather than
274    /// started into a relay that can never reach that node. Checked at
275    /// startup, not at connect time, because the failure is total and
276    /// permanent -- there is nothing to retry.
277    pub fn validate_call_home(&self) -> Result<(), C2ConfigError> {
278        if self.nodes.is_empty() && self.enrollment.is_none() {
279            return Err(C2ConfigError::NodeCount(0));
280        }
281        let waiting = self.nodes.iter().find(|node| node.route == C2NodeRoute::CallHome);
282        match (waiting, self.node_listen) {
283            (Some(node), None) => Err(C2ConfigError::CallHomeWithoutListener(node.node_id.clone())),
284            (None, None) if self.enrollment.is_some() => Err(C2ConfigError::EnrollmentWithoutListener),
285            _ => Ok(()),
286        }
287    }
288
289    pub fn with_timings(mut self, timings: C2Timings) -> Self {
290        self.timings = timings;
291        self
292    }
293
294    /// Deprecated (A2): records the path but C2 does not listen on it.
295    pub fn with_control_endpoint(mut self, endpoint: impl Into<String>) -> Result<Self, C2ConfigError> {
296        let endpoint = endpoint.into();
297        validate_control_endpoint(&endpoint)?;
298        if self.nodes.iter().any(|node| endpoints_equal(&node.endpoint, &endpoint)) {
299            return Err(C2ConfigError::ControlEndpointConflict);
300        }
301        self.control_endpoint = endpoint;
302        Ok(self)
303    }
304
305    /// TCP door for an HQ WireGuard client. Bound to one concrete address
306    /// (the address on the C2 tunnel), never `0.0.0.0` or port 0. Does not
307    /// replace the unix control listener.
308    /// Deprecated (A2): records the address but C2 does not listen on it.
309    pub fn with_control_tunnel_listen(mut self, listen: SocketAddr) -> Result<Self, C2ConfigError> {
310        if listen.port() == 0 || listen.ip().is_unspecified() {
311            return Err(C2ConfigError::InvalidControlTunnel(listen));
312        }
313        if listen == self.api_listen || self.node_listen == Some(listen) {
314            return Err(C2ConfigError::InvalidControlTunnel(listen));
315        }
316        self.control_tunnel_listen = Some(listen);
317        Ok(self)
318    }
319}
320
321fn validate_control_endpoint(endpoint: &str) -> Result<(), C2ConfigError> {
322    if !valid_local_endpoint(endpoint) {
323        return Err(C2ConfigError::InvalidControlEndpoint);
324    }
325    Ok(())
326}
327
328fn parse_node_endpoint(endpoint: &str) -> Option<(String, C2NodeRoute)> {
329    // The one assignment that names no address, because there is nothing
330    // to address: this node will arrive on the call-home listener under
331    // its own name. Spelled as a word rather than an empty value so a
332    // config reader can tell "waits to be called" from "somebody forgot to
333    // fill this in".
334    if endpoint == "accept" {
335        return Some((endpoint.to_owned(), C2NodeRoute::CallHome));
336    }
337    if let Some(authority) = endpoint.strip_prefix("tcp://") {
338        let address = authority.parse::<SocketAddr>().ok()?;
339        let is_exact_loopback = match address.ip() {
340            std::net::IpAddr::V4(ip) => ip == std::net::Ipv4Addr::LOCALHOST,
341            std::net::IpAddr::V6(ip) => ip == std::net::Ipv6Addr::LOCALHOST,
342        };
343        if !is_exact_loopback || address.port() == 0 {
344            return None;
345        }
346        return Some((format!("tcp://{address}"), C2NodeRoute::SshForwardedLoopback(address)));
347    }
348    if endpoint.contains("://") || !valid_local_endpoint(endpoint) {
349        return None;
350    }
351    Some((endpoint.to_owned(), C2NodeRoute::Local))
352}
353
354#[cfg(windows)]
355fn valid_local_endpoint(endpoint: &str) -> bool {
356    endpoint.starts_with(r"\\.\pipe\") && endpoint.len() > r"\\.\pipe\".len()
357        && endpoint.len() <= MAX_C2_ENDPOINT_BYTES
358}
359
360#[cfg(unix)]
361fn valid_local_endpoint(endpoint: &str) -> bool {
362    validate_unix_endpoint(Path::new(endpoint)).is_ok()
363}
364
365#[cfg(unix)]
366fn validate_unix_endpoint(endpoint: &Path) -> Result<(), ()> {
367    const MAX_UNIX_ENDPOINT_BYTES: usize = 103;
368    use std::os::unix::ffi::OsStrExt;
369
370    if !endpoint.is_absolute() || endpoint.file_name().is_none()
371        || endpoint.as_os_str().as_bytes().len() > MAX_UNIX_ENDPOINT_BYTES
372    {
373        return Err(());
374    }
375    Ok(())
376}
377
378#[cfg(windows)]
379fn endpoint_key(endpoint: &str) -> String {
380    if endpoint.starts_with("tcp://") { endpoint.to_owned() } else { endpoint.to_ascii_lowercase() }
381}
382
383#[cfg(unix)]
384fn endpoint_key(endpoint: &str) -> String { endpoint.to_owned() }
385
386fn endpoints_equal(left: &str, right: &str) -> bool { endpoint_key(left) == endpoint_key(right) }
387
388fn validate_token(token: &str) -> Result<(), C2ConfigError> {
389    if token.is_empty() || token.len() > 4096 || !token.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) {
390        return Err(C2ConfigError::InvalidToken);
391    }
392    Ok(())
393}
394
395#[derive(Debug, Error)]
396pub enum C2ConfigError {
397    #[error("C2 tokens must contain 1..=4096 visible ASCII bytes without whitespace")]
398    InvalidToken,
399    #[cfg_attr(windows, error("node '{0}' requires a bounded Windows named pipe or exact loopback TCP endpoint"))]
400    #[cfg_attr(unix, error("node '{0}' requires a bounded local socket or exact loopback TCP endpoint"))]
401    InvalidEndpoint(NodeId),
402    #[error("C2 API listen address must be loopback: {0}")]
403    NonLoopback(SocketAddr),
404    #[error("C2 requires 1..=64 configured nodes; received {0}")]
405    NodeCount(usize),
406    #[error("C2 node IDs must be unique")]
407    DuplicateNode,
408    #[error("C2 node endpoints must be unique")]
409    DuplicateEndpoint,
410    #[cfg_attr(windows, error("C2 control endpoint must be a bounded local Windows named pipe"))]
411    #[cfg_attr(unix, error("C2 control endpoint must be a bounded local endpoint"))]
412    InvalidControlEndpoint,
413    #[error("enrollment needs a --node-listen address for the nodes to call home on")]
414    EnrollmentWithoutListener,
415    #[error("restreamer core token must be at least 16 bytes")]
416    RestreamerToken,
417    #[error("restreamer node and drain listen addresses must differ")]
418    RestreamerListenConflict,
419    #[error("C2 control endpoint must not equal a configured node endpoint")]
420    ControlEndpointConflict,
421    #[error("C2 control tunnel listen must be a concrete non-unspecified address with a nonzero port, not shared with another listener: {0}")]
422    InvalidControlTunnel(SocketAddr),
423    #[error("C2 node call-home listen must be loopback or a concrete C2 tunnel address with a nonzero port (not 0.0.0.0, not shared with another listener): {0}")]
424    NonLoopbackNodeListen(SocketAddr),
425    #[error("C2 node call-home listen address must not equal the API listen address")]
426    NodeListenConflict,
427    #[error("node '{0}' waits to be called but no --node-listen address was configured")]
428    CallHomeWithoutListener(NodeId),
429    #[cfg(unix)]
430    #[error("C2 default runtime endpoint is unavailable: {0}")]
431    RuntimeEndpoint(String),
432}
433
434/// Presence endpoint for a node (A2): no g4a on C2 — only call-home liveness.
435#[derive(Clone)]
436struct RelayRegistry {
437    map: Arc<std::sync::RwLock<Arc<BTreeMap<NodeId, RelayEndpoint>>>>,
438    gate: Option<Arc<dyn crate::enroll_service::CapabilityGate>>,
439}
440
441impl RelayRegistry {
442    fn new(initial: BTreeMap<NodeId, RelayEndpoint>) -> Self {
443        Self { map: Arc::new(std::sync::RwLock::new(Arc::new(initial))), gate: None }
444    }
445    fn allows(&self, node_id: &NodeId, capability: &str) -> bool {
446        self.gate.as_ref().and_then(|gate| gate.allows(node_id.as_str(), capability)).unwrap_or(true)
447    }
448    fn redact(&self, status: &mut StatusResponse) {
449        for (id, node) in status.nodes.iter_mut() {
450            if !self.allows(id, "attach") {
451                node.inventory = None;
452            }
453        }
454    }
455    fn snapshot(&self) -> Arc<BTreeMap<NodeId, RelayEndpoint>> {
456        Arc::clone(&self.map.read().expect("relay registry lock"))
457    }
458    fn insert(&self, node_id: NodeId, endpoint: RelayEndpoint) {
459        let mut guard = self.map.write().expect("relay registry lock");
460        let mut next = (**guard).clone();
461        next.insert(node_id, endpoint);
462        *guard = Arc::new(next);
463    }
464}
465
466#[derive(Clone)]
467struct RelayEndpoint {
468    force_disconnect: watch::Sender<u64>,
469}
470
471#[derive(Debug, Error)]
472pub enum C2Error {
473    #[error("C2 API failed: {0}")]
474    Api(#[from] io::Error),
475    #[error("C2 task failed: {0}")]
476    Task(#[from] tokio::task::JoinError),
477}
478
479#[derive(Clone)]
480pub struct C2ShutdownHandle {
481    shutdown: watch::Sender<bool>,
482}
483
484impl C2ShutdownHandle {
485    pub fn shutdown(&self) { let _ = self.shutdown.send(true); }
486}
487
488pub struct C2Running {
489    api_addr: SocketAddr,
490    shutdown: C2ShutdownHandle,
491    task: Option<JoinHandle<Result<(), C2Error>>>,
492}
493
494impl C2Running {
495    pub async fn start(config: C2Config) -> Result<Self, C2Error> {
496        prepare_default_control_parent(&config.control_endpoint)?;
497        let listener = TcpListener::bind(config.api_listen).await?;
498        let api_addr = listener.local_addr()?;
499        let (shutdown_tx, shutdown_rx) = watch::channel(false);
500        let shutdown = C2ShutdownHandle { shutdown: shutdown_tx };
501        let task = tokio::spawn(run_bound(config, listener, shutdown_rx));
502        Ok(Self { api_addr, shutdown, task: Some(task) })
503    }
504
505    pub fn api_addr(&self) -> SocketAddr { self.api_addr }
506    pub fn shutdown_handle(&self) -> C2ShutdownHandle { self.shutdown.clone() }
507    pub async fn wait(mut self) -> Result<(), C2Error> {
508        self.task.take().expect("C2 task is present").await?
509    }
510}
511
512#[cfg(windows)]
513fn prepare_default_control_parent(_endpoint: &str) -> io::Result<()> { Ok(()) }
514
515#[cfg(unix)]
516fn prepare_default_control_parent(endpoint: &str) -> io::Result<()> {
517    let default = default_c2_control_endpoint()
518        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
519    if endpoint != default { return Ok(()); }
520    let parent = Path::new(endpoint).parent().ok_or_else(|| {
521        io::Error::new(io::ErrorKind::InvalidInput, "default C2 endpoint has no parent")
522    })?;
523    std::fs::create_dir_all(parent)?;
524    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
525}
526
527impl Drop for C2Running {
528    fn drop(&mut self) {
529        self.shutdown.shutdown();
530        if let Some(task) = self.task.take() { task.abort(); }
531    }
532}
533
534/// A socket handed from the call-home listener to a node's relay worker, with the
535/// per-connection session token when the node authenticated by signature.
536type CallHomeSocket = (TcpStream, Option<String>);
537type CallHomeRoutes = Arc<Mutex<BTreeMap<NodeId, mpsc::Sender<CallHomeSocket>>>>;
538
539struct Spawner {
540    timings: C2Timings,
541    relay_senders: RelayRegistry,
542    call_home_routes: CallHomeRoutes,
543    ingress: mpsc::Sender<Attempt>,
544}
545
546impl Spawner {
547    /// Presence worker for a runtime-enrolled node (call-home liveness only; no g4a).
548    fn spawn_enrolled(&self, node_id: &NodeId, shutdown: &watch::Receiver<bool>, tasks: &mut JoinSet<io::Result<()>>) {
549        if self.relay_senders.snapshot().contains_key(node_id) {
550            return;
551        }
552        let placeholder = "0".repeat(64);
553        let Ok(node) = C2NodeConfig::new(node_id.clone(), "accept", placeholder) else { return };
554        let (force_tx, force_rx) = watch::channel(0_u64);
555        let (call_home_tx, call_home_rx) = mpsc::channel(1);
556        self.call_home_routes.lock().expect("routes lock").insert(node_id.clone(), call_home_tx);
557        self.relay_senders.insert(node_id.clone(), RelayEndpoint { force_disconnect: force_tx });
558        tasks.spawn(node_presence_worker(
559            node,
560            self.timings,
561            force_rx,
562            Some(call_home_rx),
563            self.ingress.clone(),
564            shutdown.clone(),
565        ));
566        tracing::info!(%node_id, "presence worker created for enrolled node");
567    }
568}
569
570async fn run_bound(config: C2Config, listener: TcpListener, mut shutdown: watch::Receiver<bool>) -> Result<(), C2Error> {
571    let now = unix_ms();
572    let nodes = config.nodes.iter().map(|node| {
573        (node.node_id.clone(), initial_observed_node(node))
574    }).collect();
575    let initial = Arc::new(StatusResponse { api_version: C2_API_VERSION, ready: config.nodes.is_empty(), observed_at_unix_ms: now, nodes });
576    let (status_tx, status_rx) = watch::channel(initial);
577    let (ingress_tx, ingress_rx) = mpsc::channel(config.nodes.len().saturating_mul(2).max(16));
578    let mut tasks = JoinSet::new();
579    let mut relay_senders = BTreeMap::new();
580    let mut presence_receivers = Vec::new();
581    let mut static_routes: BTreeMap<NodeId, mpsc::Sender<CallHomeSocket>> = BTreeMap::new();
582    for node in config.nodes.clone() {
583        let (force_tx, force_rx) = watch::channel(0_u64);
584        relay_senders.insert(node.node_id.clone(), RelayEndpoint { force_disconnect: force_tx });
585        let call_home_rx = if node.route == C2NodeRoute::CallHome {
586            let (call_home_tx, call_home_rx) = mpsc::channel(1);
587            static_routes.insert(node.node_id.clone(), call_home_tx);
588            Some(call_home_rx)
589        } else {
590            None
591        };
592        presence_receivers.push((node, force_rx, call_home_rx));
593    }
594    let static_ids: BTreeSet<NodeId> = static_routes.keys().cloned().collect();
595    let call_home_routes: CallHomeRoutes = Arc::new(Mutex::new(static_routes));
596    let mut relay_senders = RelayRegistry::new(relay_senders);
597    // Enrollment: registry, listener, runtime node adds.
598    let mut enroll_events = None;
599    let mut enroll_service = None;
600    if let Some(enrollment) = &config.enrollment {
601        let (service, events) = crate::enroll_service::EnrollService::start(enrollment)
602            .map_err(|error| C2Error::Api(io::Error::other(error.to_string())))?;
603        let enroll_listener = TcpListener::bind(enrollment.listen).await?;
604        tasks.spawn(crate::enroll_service::serve_enroll(enroll_listener, Arc::clone(&service), shutdown.clone()));
605        relay_senders.gate = Some(Arc::clone(&service) as Arc<dyn crate::enroll_service::CapabilityGate>);
606        enroll_events = Some(events);
607        enroll_service = Some(service);
608    }
609    if let Some(node_listen) = config.node_listen {
610        tasks.spawn(accept_call_home(node_listen, Arc::clone(&call_home_routes), static_ids, enroll_service.clone(), shutdown.clone()));
611    }
612    // A2 restreamer: opaque node uplink + core drain (C2 never dials core).
613    if let (Some(node_listen), Some(drain_listen), Some(core_token)) = (
614        config.restreamer_node_listen,
615        config.restreamer_drain_listen,
616        config.restreamer_core_token.clone(),
617    ) {
618        let journal = Arc::new(crate::restreamer::MemoryJournal::new());
619        let restreamer = crate::restreamer::Restreamer::new(
620            crate::restreamer::BufferLimits::default(),
621            journal,
622            core_token.clone(),
623            None,
624        );
625        let cfg = crate::restreamer::RestreamerConfig {
626            node_listen,
627            drain_listen,
628            buffer: crate::restreamer::BufferLimits::default(),
629            core_token,
630        };
631        let rs = restreamer.clone();
632        let sd = shutdown.clone();
633        tasks.spawn(async move {
634            crate::restreamer::serve(cfg, rs, sd)
635                .await
636                .map_err(|error| io::Error::other(error.to_string()))
637        });
638        tracing::info!(%node_listen, %drain_listen, "A2 restreamer listening");
639    }
640    tasks.spawn(inventory_owner(config.timings.fresh_for, ingress_rx, status_tx, shutdown.clone()));
641    tracing::info!("A2: g4a node relays removed; call-home presence only");
642    for (node, force_disconnect, call_home) in presence_receivers {
643        tasks.spawn(node_presence_worker(node, config.timings, force_disconnect, call_home, ingress_tx.clone(), shutdown.clone()));
644    }
645    let spawner = Spawner {
646        timings: config.timings,
647        relay_senders: relay_senders.clone(),
648        call_home_routes: Arc::clone(&call_home_routes),
649        ingress: ingress_tx.clone(),
650    };
651    drop(ingress_tx);
652    tasks.spawn(http_server(listener, config.api_token.clone(), config.timings.http_io_deadline, status_rx.clone(), enroll_service.clone(), shutdown.clone()));
653    // A2: no operator control listener on C2. Core opens the drain and parses g4a.
654    let _ = (config.control_endpoint, config.control_tunnel_listen, status_rx);
655    loop {
656        tokio::select! {
657            changed = shutdown.changed() => {
658                if changed.is_err() || *shutdown.borrow() { break; }
659            }
660            event = async {
661                match enroll_events.as_mut() {
662                    Some(events) => events.recv().await,
663                    None => std::future::pending().await,
664                }
665            } => {
666                match event {
667                    Some(crate::enroll_service::EnrollEvent::Enrolled(id)) => {
668                        if let Ok(node_id) = NodeId::new(id) { spawner.spawn_enrolled(&node_id, &shutdown, &mut tasks); }
669                    }
670                    Some(crate::enroll_service::EnrollEvent::StatusChanged(id, _)) => {
671                        // A node that no longer holds the heartbeat capability is dropped.
672                        if let Ok(node_id) = NodeId::new(id) {
673                            if !relay_senders.allows(&node_id, "heartbeat") {
674                                if let Some(endpoint) = relay_senders.snapshot().get(&node_id) {
675                                    endpoint.force_disconnect.send_modify(|generation| *generation = generation.wrapping_add(1));
676                                }
677                            }
678                        }
679                    }
680                    Some(crate::enroll_service::EnrollEvent::PolicyChanged) => {
681                        if let Some(service) = &enroll_service {
682                            for id in service.nodes_without("heartbeat") {
683                                if let Ok(node_id) = NodeId::new(id) {
684                                    if let Some(endpoint) = relay_senders.snapshot().get(&node_id) {
685                                        endpoint.force_disconnect.send_modify(|generation| *generation = generation.wrapping_add(1));
686                                    }
687                                }
688                            }
689                        }
690                    }
691                    None => enroll_events = None,
692                }
693            }
694            result = tasks.join_next() => {
695                match result {
696                    Some(Ok(Ok(()))) if *shutdown.borrow() => break,
697                    Some(Ok(Ok(()))) => return Err(C2Error::Api(io::Error::new(io::ErrorKind::Other, "C2 task exited unexpectedly"))),
698                    Some(Ok(Err(error))) => return Err(C2Error::Api(error)),
699                    Some(Err(error)) => return Err(C2Error::Task(error)),
700                    None => break,
701                }
702            }
703        }
704    }
705    tasks.shutdown().await;
706    Ok(())
707}
708
709fn initial_enrolled_node() -> ObservedNode {
710    ObservedNode {
711        endpoint: "call-home".to_owned(), transport_label: "call-home".to_owned(),
712        transport: NodeTransportState::Offline, freshness: NodeFreshness::Unavailable,
713        cursor: None, inventory: None, last_attempt_unix_ms: None, last_success_unix_ms: None,
714        consecutive_failures: 0, last_error: None, gaps: Vec::new(), gaps_truncated: 0,
715    }
716}
717
718fn initial_observed_node(node: &C2NodeConfig) -> ObservedNode {
719    ObservedNode {
720        endpoint: node.endpoint.clone(), transport_label: node.transport_label().to_owned(),
721        transport: NodeTransportState::Offline, freshness: NodeFreshness::Unavailable,
722        cursor: None, inventory: None, last_attempt_unix_ms: None, last_success_unix_ms: None,
723        consecutive_failures: 0, last_error: None, gaps: Vec::new(), gaps_truncated: 0,
724    }
725}
726
727#[cfg(windows)]
728fn local_transport_label() -> &'static str { "windows-named-pipe" }
729
730#[cfg(unix)]
731fn local_transport_label() -> &'static str { "unix-domain-socket" }
732
733#[derive(Clone, Default)]
734struct ProviderContractManifest {
735    provider_contracts: Vec<ProviderContractSupport>,
736    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
737}
738
739impl ProviderContractManifest {
740    fn from_compatibility(compatibility: Option<&NegotiatedNodeCompatibility>) -> Self {
741        let Some(compatibility) = compatibility.filter(|compatibility| {
742            compatibility.capabilities.iter().any(|capability| {
743                capability.as_str() == C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY
744            })
745        }) else {
746            return Self::default();
747        };
748        Self {
749            provider_contracts: compatibility.provider_contracts.clone(),
750            provider_adapter_contracts: compatibility.provider_adapter_contracts.clone(),
751        }
752    }
753}
754
755enum AttemptResult {
756    Connected {
757        cursor: NodeCursor,
758        snapshot: NodeSnapshot,
759        gaps: Vec<GapKind>,
760        provider_contract_manifest: ProviderContractManifest,
761    },
762    Success { cursor: NodeCursor, snapshot: NodeSnapshot, gaps: Vec<GapKind> },
763    Cursor {
764        cursor: NodeCursor,
765        gaps: Vec<GapKind>,
766        managed_worktree_events: Vec<NodeEvent>,
767    },
768    Failure { error: SanitizedError, hard: bool },
769}
770
771struct Attempt { node_id: NodeId, at_unix_ms: u64, result: AttemptResult }
772
773/// Accepts node connections and hands each one to the relay worker for
774/// whichever node it says it is.
775///
776/// This is the only place in the relay that learns a node's identity from
777/// the wire rather than from configuration, and it is careful about what
778/// that means: the announced name SELECTS a worker and nothing more. The
779/// worker then runs the same mutual challenge-response it runs on a
780/// dialled connection, against the token configured for that node, so a
781/// caller that announced a name it cannot prove gets no further than the
782/// next frame.
783///
784/// A socket for an unknown node, an unreadable preface, or a node whose
785/// slot is already full is dropped with a line saying which -- never
786/// silently, because "the node is calling but nothing happens" is exactly
787/// the failure that is impossible to diagnose from the node's end.
788async fn accept_call_home(
789    listen: SocketAddr,
790    routes: CallHomeRoutes,
791    static_ids: BTreeSet<NodeId>,
792    enroll: Option<Arc<crate::enroll_service::EnrollService>>,
793    mut shutdown: watch::Receiver<bool>,
794) -> io::Result<()> {
795    let listener = TcpListener::bind(listen).await?;
796    tracing::info!(%listen, waiting_nodes = routes.lock().expect("routes lock").len(), "call-home listener bound");
797    loop {
798        if *shutdown.borrow() {
799            return Ok(());
800        }
801        let (stream, peer) = tokio::select! {
802            accepted = listener.accept() => accepted?,
803            changed = shutdown.changed() => {
804                if changed.is_err() || *shutdown.borrow() { return Ok(()); }
805                continue;
806            }
807        };
808        let routes = Arc::clone(&routes);
809        let static_ids = static_ids.clone();
810        let enroll = enroll.clone();
811        // Off the accept loop: the preface has its own deadline, and one
812        // peer that connects and then says nothing must not stop every
813        // other node from being accepted meanwhile.
814        tokio::spawn(async move {
815            let mut stream = stream;
816            let _ = stream.set_nodelay(true);
817            let node_id = match read_call_home_announce(&mut stream).await {
818                Ok(node_id) => node_id,
819                Err(error) => {
820                    tracing::warn!(%peer, cause = %error, "call-home connection rejected");
821                    return;
822                }
823            };
824            // Token nodes (configured with --node) keep the original path. Enrolled nodes
825            // prove their identity by signing a challenge; the exchange yields a
826            // per-connection session token for the unchanged wire handshake.
827            let mut session_token = None;
828            if !static_ids.contains(&node_id) {
829                let Some(enroll) = enroll.as_ref() else {
830                    tracing::warn!(%peer, %node_id, "call-home connection named a node this relay does not wait for");
831                    return;
832                };
833                match enroll.signed_call_home(&mut stream, node_id.as_str()).await {
834                    Ok(token) => session_token = Some(token),
835                    Err(cause) => {
836                        tracing::warn!(%peer, %node_id, %cause, "signed call-home refused");
837                        return;
838                    }
839                }
840            }
841            let sender = routes.lock().expect("routes lock").get(&node_id).cloned();
842            let Some(sender) = sender else {
843                tracing::warn!(
844                    %peer,
845                    %node_id,
846                    "call-home connection named a node this relay does not wait for",
847                );
848                return;
849            };
850            match sender.try_send((stream, session_token)) {
851                Ok(()) => tracing::info!(%peer, %node_id, "node called home"),
852                Err(mpsc::error::TrySendError::Full(_)) => tracing::warn!(
853                    %peer,
854                    %node_id,
855                    "node called home while its previous connection is still being taken up",
856                ),
857                Err(mpsc::error::TrySendError::Closed(_)) => tracing::warn!(
858                    %peer,
859                    %node_id,
860                    "node called home but its relay worker is gone",
861                ),
862            }
863        });
864    }
865}
866
867/// Blocks until this node calls in, or shutdown -- with no deadline of its
868/// own on purpose.
869///
870/// A dialled connection either answers within `attempt_deadline` or has
871/// failed, and treating a slow answer as a failure is right there. Waiting
872/// for a node to call is the opposite: a node that has not called yet has
873/// not failed at anything, and it may be minutes from starting. Putting
874/// that wait under the attempt deadline would turn an idle relay into a
875/// stream of "connection deadline exceeded" warnings about nodes that are
876/// merely not running. The handshake that FOLLOWS is deadlined normally --
877/// a peer that connects and then stalls is a real failure.
878async fn await_call_home(
879    receiver: Option<&mut mpsc::Receiver<CallHomeSocket>>,
880    shutdown: &mut watch::Receiver<bool>,
881) -> Option<CallHomeSocket> {
882    let receiver = receiver?;
883    loop {
884        if *shutdown.borrow() {
885            return None;
886        }
887        tokio::select! {
888            stream = receiver.recv() => return stream,
889            changed = shutdown.changed() => {
890                if changed.is_err() || *shutdown.borrow() { return None; }
891            }
892        }
893    }
894}
895
896async fn node_presence_worker(
897    node: C2NodeConfig,
898    timings: C2Timings,
899    mut force_disconnect: watch::Receiver<u64>,
900    mut call_home: Option<mpsc::Receiver<CallHomeSocket>>,
901    ingress: mpsc::Sender<Attempt>,
902    mut shutdown: watch::Receiver<bool>,
903) -> io::Result<()> {
904    // A2: C2 never speaks g4a. Call-home only proves the node can reach edge;
905    // spawn/inventory go through the opaque restreamer to core.
906    let _ = timings;
907    loop {
908        if *shutdown.borrow() { return Ok(()); }
909        let adopted = if node.route == C2NodeRoute::CallHome {
910            match await_call_home(call_home.as_mut(), &mut shutdown).await {
911                Some((stream, _token)) => stream,
912                None => return Ok(()),
913            }
914        } else {
915            // Dialled/local routes are unused under A2 opaque restreamer.
916            tokio::select! {
917                changed = shutdown.changed() => {
918                    if changed.is_err() || *shutdown.borrow() { return Ok(()); }
919                }
920                _ = force_disconnect.changed() => {}
921                _ = tokio::time::sleep(Duration::from_secs(60)) => {}
922            }
923            continue;
924        };
925        let incarnation_id = gate4agent_node_wire::random_incarnation_id()
926            .unwrap_or_else(|_| NodeIncarnationId::from_bytes([0u8; 16]));
927        let cursor = NodeCursor { incarnation_id, sequence: 0 };
928        let snapshot = NodeSnapshot {
929            node_id: node.node_id.clone(),
930            enabled_providers: Vec::new(),
931            provider_runtime_statuses: Default::default(),
932            workspaces: Vec::new(),
933            session_records: Vec::new(),
934            managed_worktrees: Vec::new(),
935            launch_inventory: None,
936            agent_progress: Vec::new(),
937        };
938        ingress_attempt(&ingress, &node.node_id, AttemptResult::Connected {
939            cursor,
940            snapshot,
941            gaps: Vec::new(),
942            provider_contract_manifest: ProviderContractManifest::default(),
943        }).await?;
944        tracing::info!(node_id = %node.node_id, "node present on call-home (no g4a on C2)");
945        let mut stream = adopted;
946        let mut buf = [0u8; 256];
947        let drop_reason = loop {
948            tokio::select! {
949                changed = force_disconnect.changed() => {
950                    let _ = changed;
951                    break "forced disconnect";
952                }
953                changed = shutdown.changed() => {
954                    if changed.is_err() || *shutdown.borrow() {
955                        return Ok(());
956                    }
957                }
958                read = tokio::io::AsyncReadExt::read(&mut stream, &mut buf) => {
959                    match read {
960                        Ok(0) => break "call-home closed",
961                        Ok(_) => {}
962                        Err(_) => break "call-home read error",
963                    }
964                }
965            }
966        };
967        tracing::info!(node_id = %node.node_id, reason = drop_reason, "node presence ended");
968        ingress_attempt(&ingress, &node.node_id, AttemptResult::Failure {
969            error: SanitizedError { category: C2ErrorCategory::Transport, message: drop_reason.to_owned() },
970            hard: false,
971        }).await?;
972    }
973}
974
975async fn ingress_attempt(
976    ingress: &mpsc::Sender<Attempt>,
977    node_id: &NodeId,
978    result: AttemptResult,
979) -> io::Result<()> {
980    ingress.send(Attempt { node_id: node_id.clone(), at_unix_ms: unix_ms(), result }).await
981        .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "inventory owner closed"))
982}
983
984async fn inventory_owner(
985    fresh_for: Duration, mut ingress: mpsc::Receiver<Attempt>,
986    status: watch::Sender<Arc<StatusResponse>>, mut shutdown: watch::Receiver<bool>,
987) -> io::Result<()> {
988    let mut current = (**status.borrow()).clone();
989    let mut attempted = BTreeSet::new();
990    loop {
991        tokio::select! {
992            attempt = ingress.recv() => {
993                let Some(attempt) = attempt else { return Ok(()); };
994                attempted.insert(attempt.node_id.clone());
995                let node = current.nodes.entry(attempt.node_id.clone()).or_insert_with(initial_enrolled_node);
996                node.last_attempt_unix_ms = Some(attempt.at_unix_ms);
997                match attempt.result {
998                    AttemptResult::Connected {
999                        cursor,
1000                        snapshot,
1001                        gaps,
1002                        provider_contract_manifest,
1003                    } => {
1004                        let previous = node.cursor;
1005                        let mut inventory = SlimNodeInventory::from_snapshot(&snapshot);
1006                        inventory.provider_contracts =
1007                            provider_contract_manifest.provider_contracts;
1008                        inventory.provider_adapter_contracts =
1009                            provider_contract_manifest.provider_adapter_contracts;
1010                        node.transport = NodeTransportState::Online;
1011                        node.freshness = NodeFreshness::Fresh;
1012                        node.cursor = Some(cursor);
1013                        node.inventory = Some(inventory);
1014                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1015                        node.consecutive_failures = 0;
1016                        node.last_error = None;
1017                        for kind in gaps {
1018                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1019                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1020                        }
1021                    }
1022                    AttemptResult::Success { cursor, snapshot, gaps } => {
1023                        let previous = node.cursor;
1024                        let provider_contract_manifest = node.inventory.as_ref().map(|inventory| {
1025                            ProviderContractManifest {
1026                                provider_contracts: inventory.provider_contracts.clone(),
1027                                provider_adapter_contracts: inventory.provider_adapter_contracts.clone(),
1028                            }
1029                        }).unwrap_or_default();
1030                        let mut inventory = SlimNodeInventory::from_snapshot(&snapshot);
1031                        inventory.provider_contracts =
1032                            provider_contract_manifest.provider_contracts;
1033                        inventory.provider_adapter_contracts =
1034                            provider_contract_manifest.provider_adapter_contracts;
1035                        node.transport = NodeTransportState::Online;
1036                        node.freshness = NodeFreshness::Fresh;
1037                        node.cursor = Some(cursor);
1038                        node.inventory = Some(inventory);
1039                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1040                        node.consecutive_failures = 0;
1041                        node.last_error = None;
1042                        for kind in gaps {
1043                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1044                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1045                        }
1046                    }
1047                    AttemptResult::Cursor {
1048                        cursor,
1049                        gaps,
1050                        managed_worktree_events,
1051                    } => {
1052                        let previous = node.cursor;
1053                        let incarnation_changed = previous.is_some_and(|previous| {
1054                            previous.incarnation_id != cursor.incarnation_id
1055                        });
1056                        apply_managed_worktree_cursor(
1057                            node.inventory.as_mut(),
1058                            incarnation_changed,
1059                            &managed_worktree_events,
1060                        );
1061                        node.transport = NodeTransportState::Online;
1062                        node.freshness = NodeFreshness::Fresh;
1063                        node.cursor = Some(cursor);
1064                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1065                        node.consecutive_failures = 0;
1066                        node.last_error = None;
1067                        for kind in gaps {
1068                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1069                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1070                        }
1071                    }
1072                    AttemptResult::Failure { error, hard } => {
1073                        node.consecutive_failures = node.consecutive_failures.saturating_add(1);
1074                        node.transport = if hard || node.consecutive_failures >= 5 { NodeTransportState::Parked } else { NodeTransportState::Offline };
1075                        node.last_error = Some(error);
1076                    }
1077                }
1078                current.ready = current.nodes.keys().all(|id| attempted.contains(id));
1079                refresh_freshness(&mut current, fresh_for);
1080                current.observed_at_unix_ms = unix_ms();
1081                status.send_replace(Arc::new(current.clone()));
1082            }
1083            _ = sleep(Duration::from_millis(250)) => {
1084                refresh_freshness(&mut current, fresh_for);
1085                current.observed_at_unix_ms = unix_ms();
1086                status.send_replace(Arc::new(current.clone()));
1087            }
1088            changed = shutdown.changed() => if changed.is_err() || *shutdown.borrow() { return Ok(()); },
1089        }
1090    }
1091}
1092
1093fn apply_managed_worktree_cursor(
1094    inventory: Option<&mut SlimNodeInventory>,
1095    incarnation_changed: bool,
1096    events: &[NodeEvent],
1097) {
1098    let Some(inventory) = inventory else { return; };
1099    if incarnation_changed {
1100        inventory.provider_runtime_statuses.clear();
1101        inventory.managed_worktrees.clear();
1102        inventory.managed_worktree_count = 0;
1103        inventory.managed_worktrees_truncated = false;
1104        return;
1105    }
1106    for event in events {
1107        match event {
1108            NodeEvent::ManagedWorktreeUpserted { lease } => {
1109                inventory.managed_worktrees.retain(|existing| {
1110                    existing.lease_id != lease.lease_id
1111                        && existing.workspace_id != lease.workspace_id
1112                });
1113                inventory.managed_worktrees.push(lease.clone());
1114                inventory.managed_worktrees.sort_by(|left, right| {
1115                    left.lease_id.cmp(&right.lease_id)
1116                });
1117                inventory.managed_worktree_count = inventory.managed_worktrees.len();
1118                inventory.managed_worktrees.truncate(
1119                    crate::protocol::MAX_C2_MANAGED_WORKTREES_PER_NODE,
1120                );
1121                inventory.managed_worktrees_truncated =
1122                    inventory.managed_worktrees.len() < inventory.managed_worktree_count;
1123            }
1124            NodeEvent::ManagedWorktreeRemoved { lease_id } => {
1125                let before = inventory.managed_worktrees.len();
1126                inventory
1127                    .managed_worktrees
1128                    .retain(|lease| &lease.lease_id != lease_id);
1129                if inventory.managed_worktrees.len() < before
1130                    || inventory.managed_worktrees_truncated
1131                {
1132                    inventory.managed_worktree_count =
1133                        inventory.managed_worktree_count.saturating_sub(1);
1134                }
1135                inventory.managed_worktrees_truncated =
1136                    inventory.managed_worktrees.len() < inventory.managed_worktree_count;
1137            }
1138            _ => {}
1139        }
1140    }
1141}
1142
1143fn refresh_freshness(status: &mut StatusResponse, fresh_for: Duration) {
1144    let now = unix_ms();
1145    let fresh_ms = fresh_for.as_millis().min(u64::MAX as u128) as u64;
1146    for node in status.nodes.values_mut() {
1147        node.freshness = match node.last_success_unix_ms {
1148            None => NodeFreshness::Unavailable,
1149            Some(last) if now.saturating_sub(last) <= fresh_ms => NodeFreshness::Fresh,
1150            Some(_) => NodeFreshness::Stale,
1151        };
1152    }
1153}
1154
1155async fn http_server(
1156    listener: TcpListener, token: String, io_deadline: Duration,
1157    status: watch::Receiver<Arc<StatusResponse>>,
1158    enroll: Option<Arc<crate::enroll_service::EnrollService>>,
1159    mut shutdown: watch::Receiver<bool>,
1160) -> io::Result<()> {
1161    let permits = Arc::new(Semaphore::new(MAX_HTTP_CONNECTIONS));
1162    let mut connections = JoinSet::new();
1163    loop {
1164        tokio::select! {
1165            accepted = listener.accept() => {
1166                let (stream, _) = accepted?;
1167                let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { drop(stream); continue; };
1168                let token = token.clone();
1169                let status = status.clone();
1170                let enroll = enroll.clone();
1171                connections.spawn(async move { let _permit = permit; let _ = serve_http(stream, &token, io_deadline, &status, enroll.as_deref()).await; });
1172            }
1173            changed = shutdown.changed() => if changed.is_err() || *shutdown.borrow() { break; },
1174        }
1175        while let Some(result) = connections.try_join_next() { result.map_err(io::Error::other)?; }
1176    }
1177    connections.shutdown().await;
1178    Ok(())
1179}
1180
1181async fn serve_http(mut stream: TcpStream, token: &str, deadline: Duration, status: &watch::Receiver<Arc<StatusResponse>>, enroll: Option<&crate::enroll_service::EnrollService>) -> io::Result<()> {
1182    let request = match timeout(deadline, read_request(&mut stream)).await {
1183        Ok(Ok(request)) => request,
1184        Ok(Err(ReadError::TooLarge)) => return write_response(&mut stream, deadline, Response::plain(413, "Payload Too Large")).await,
1185        Ok(Err(ReadError::Io(error))) => return Err(error),
1186        _ => return Ok(()),
1187    };
1188    let response = route(request, token, status.borrow().as_ref(), enroll);
1189    write_response(&mut stream, deadline, response).await
1190}
1191
1192struct Request { method: String, path: String, authorization: Option<String>, body: Vec<u8> }
1193enum ReadError { Closed, Invalid, TooLarge, Io(io::Error) }
1194
1195async fn read_request(stream: &mut TcpStream) -> Result<Request, ReadError> {
1196    let mut bytes = Vec::with_capacity(1024);
1197    let mut chunk = [0_u8; 1024];
1198    let header_end = loop {
1199        let count = stream.read(&mut chunk).await.map_err(ReadError::Io)?;
1200        if count == 0 { return Err(ReadError::Closed); }
1201        if bytes.len().saturating_add(count) > HEADER_LIMIT_BYTES + OPERATOR_BODY_LIMIT_BYTES { return Err(ReadError::TooLarge); }
1202        bytes.extend_from_slice(&chunk[..count]);
1203        if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { break position + 4; }
1204    };
1205    let text = std::str::from_utf8(&bytes[..header_end]).map_err(|_| ReadError::Invalid)?;
1206    let mut lines = text.split("\r\n");
1207    let mut first = lines.next().ok_or(ReadError::Invalid)?.split_whitespace();
1208    let method = first.next().ok_or(ReadError::Invalid)?;
1209    let path = first.next().ok_or(ReadError::Invalid)?;
1210    let version = first.next().ok_or(ReadError::Invalid)?;
1211    if first.next().is_some() || !version.starts_with("HTTP/1.") || !path.starts_with('/') { return Err(ReadError::Invalid); }
1212    let mut authorization = None;
1213    let mut content_length = 0_usize;
1214    for line in lines {
1215        if line.is_empty() { break; }
1216        let (name, value) = line.split_once(':').ok_or(ReadError::Invalid)?;
1217        if name.eq_ignore_ascii_case("authorization") {
1218            if authorization.is_some() { return Err(ReadError::Invalid); }
1219            authorization = Some(value.trim().to_owned());
1220        } else if name.eq_ignore_ascii_case("content-length") {
1221            content_length = value.trim().parse().map_err(|_| ReadError::Invalid)?;
1222        }
1223    }
1224    if content_length > OPERATOR_BODY_LIMIT_BYTES { return Err(ReadError::TooLarge); }
1225    let mut body = bytes[header_end..].to_vec();
1226    while body.len() < content_length {
1227        let count = stream.read(&mut chunk).await.map_err(ReadError::Io)?;
1228        if count == 0 { return Err(ReadError::Closed); }
1229        body.extend_from_slice(&chunk[..count]);
1230    }
1231    body.truncate(content_length);
1232    Ok(Request { method: method.to_owned(), path: path.to_owned(), authorization, body })
1233}
1234
1235fn route(request: Request, token: &str, status: &StatusResponse, enroll: Option<&crate::enroll_service::EnrollService>) -> Response {
1236    let path = request.path.split_once('?').map_or(request.path.as_str(), |pair| pair.0);
1237    // Operator enrollment endpoints: bearer-authenticated, loopback API only.
1238    if path == "/v1/enroll" || path.starts_with("/v1/enroll/") {
1239        let Some(service) = enroll else { return Response::plain(404, "Not Found"); };
1240        if !authorized(request.authorization.as_deref(), token) { return Response::plain(401, "Unauthorized").authenticate(); }
1241        let reply = service.handle_operator(&request.method, path, &request.body);
1242        return Response::raw_json(reply.status, reply.body);
1243    }
1244    if request.method != "GET" { return Response::plain(405, "Method Not Allowed").allow_get(); }
1245    match path {
1246        "/health" => Response::json(200, &HealthResponse { ok: true, service: "gate4agent-c2".to_owned(), api_version: C2_API_VERSION, pid: std::process::id(), version: env!("CARGO_PKG_VERSION").to_owned() }),
1247        "/ready" => {
1248            let online_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Online).count();
1249            let offline_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Offline).count();
1250            let parked_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Parked).count();
1251            let body = ReadyResponse { ready: status.ready, api_version: C2_API_VERSION, configured_nodes: status.nodes.len(), attempted_nodes: status.nodes.values().filter(|node| node.last_attempt_unix_ms.is_some()).count(), online_nodes, offline_nodes, parked_nodes };
1252            Response::json(if status.ready { 200 } else { 503 }, &body)
1253        }
1254        "/status" => {
1255            if !authorized(request.authorization.as_deref(), token) { Response::plain(401, "Unauthorized").authenticate() }
1256            else {
1257                // Nodes whose status does not hold `attach` show liveness only.
1258                let mut shown = status.clone();
1259                if let Some(service) = enroll {
1260                    for (id, node) in shown.nodes.iter_mut() {
1261                        if service.allows(id.as_str(), "attach") == Some(false) { node.inventory = None; }
1262                    }
1263                }
1264                Response::json(200, &shown)
1265            }
1266        }
1267        _ => Response::plain(404, "Not Found"),
1268    }
1269}
1270
1271fn authorized(header: Option<&str>, token: &str) -> bool {
1272    let Some((scheme, candidate)) = header.and_then(|value| value.split_once(' ')) else { return false; };
1273    scheme.eq_ignore_ascii_case("bearer") && constant_time_eq(candidate.as_bytes(), token.as_bytes())
1274}
1275
1276fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
1277    if left.len() != right.len() { return false; }
1278    left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
1279}
1280
1281struct Response { status: u16, reason: &'static str, content_type: &'static str, body: Vec<u8>, headers: Vec<(&'static str, &'static str)> }
1282impl Response {
1283    fn plain(status: u16, reason: &'static str) -> Self { Self { status, reason, content_type: "text/plain; charset=utf-8", body: reason.as_bytes().to_vec(), headers: Vec::new() } }
1284    fn json<T: serde::Serialize>(status: u16, value: &T) -> Self {
1285        let body = serde_json::to_vec(value).expect("C2 DTO must serialize");
1286        if body.len() > RESPONSE_BODY_LIMIT_BYTES { return Self::plain(503, "Service Unavailable"); }
1287        let reason = if status == 200 { "OK" } else { "Service Unavailable" };
1288        Self { status, reason, content_type: "application/json", body, headers: Vec::new() }
1289    }
1290    fn raw_json(status: u16, body: Vec<u8>) -> Self {
1291        let reason = match status { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 409 => "Conflict", 429 => "Too Many Requests", _ => "Service Unavailable" };
1292        Self { status, reason, content_type: "application/json", body, headers: Vec::new() }
1293    }
1294    fn allow_get(mut self) -> Self { self.headers.push(("Allow", "GET")); self }
1295    fn authenticate(mut self) -> Self { self.headers.push(("WWW-Authenticate", "Bearer")); self }
1296}
1297
1298async fn write_response(stream: &mut TcpStream, deadline: Duration, response: Response) -> io::Result<()> {
1299    let mut headers = format!("HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n", response.status, response.reason, response.content_type, response.body.len());
1300    for (name, value) in response.headers { headers.push_str(name); headers.push_str(": "); headers.push_str(value); headers.push_str("\r\n"); }
1301    headers.push_str("\r\n");
1302    timeout(deadline, async { stream.write_all(headers.as_bytes()).await?; stream.write_all(&response.body).await?; stream.shutdown().await }).await
1303        .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "C2 HTTP write timed out"))?
1304}
1305
1306fn unix_ms() -> u64 {
1307    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64
1308}
1309
1310#[cfg(test)]
1311mod endpoint_tests {
1312    use super::*;
1313    use crate::protocol::{C2RelayRoute, C2Topology};
1314
1315    fn node(endpoint: &str) -> Result<C2NodeConfig, C2ConfigError> {
1316        C2NodeConfig::new(NodeId::new("remote-node").unwrap(), endpoint, "safe-token")
1317    }
1318
1319    #[cfg(windows)]
1320    const LOCAL_ENDPOINT: &str = r"\\.\pipe\relay-route-fact";
1321    #[cfg(unix)]
1322    const LOCAL_ENDPOINT: &str = "/tmp/gate4agent-relay-route-fact.sock";
1323
1324    fn projected_route(node: &C2NodeConfig, transport: NodeTransportState) -> C2RelayRoute {
1325        let mut observed = initial_observed_node(node);
1326        observed.transport = transport;
1327        let status = StatusResponse {
1328            api_version: C2_API_VERSION,
1329            ready: false,
1330            observed_at_unix_ms: 1,
1331            nodes: BTreeMap::from([(node.node_id.clone(), observed)]),
1332        };
1333        C2Topology::from_status(&status).nodes[0].relay_route
1334    }
1335
1336    #[test]
1337    fn relay_route_fact_projects_exactly_and_survives_offline_and_parked() {
1338        let local = node(LOCAL_ENDPOINT).unwrap();
1339        let ssh = node("tcp://127.0.0.1:48100").unwrap();
1340
1341        for transport in [
1342            NodeTransportState::Online,
1343            NodeTransportState::Offline,
1344            NodeTransportState::Parked,
1345        ] {
1346            assert_eq!(projected_route(&local, transport), C2RelayRoute::LocalIpc);
1347            assert_eq!(
1348                projected_route(&ssh, transport),
1349                C2RelayRoute::SshForwardedLoopback,
1350            );
1351        }
1352    }
1353
1354    /// `accept` is the assignment that names no address, and the config
1355    /// has to treat it as a different KIND of route rather than as a
1356    /// malformed endpoint: it is how a node that cannot be dialled is
1357    /// declared.
1358    #[test]
1359    fn a_node_that_waits_to_be_called_is_declared_by_name_not_by_address() {
1360        let waiting = node("accept").unwrap();
1361        assert_eq!(waiting.route, C2NodeRoute::CallHome);
1362        assert_eq!(waiting.transport_label(), "call-home");
1363
1364        // Every waiting node carries the same `accept`, so the
1365        // duplicate-endpoint check must not see them as two nodes fighting
1366        // over one socket. Their uniqueness is `node_id`'s job, and that
1367        // check still applies.
1368        let second = C2NodeConfig::new(
1369            NodeId::new("second-waiting-node").unwrap(),
1370            "accept",
1371            "safe-token",
1372        )
1373        .unwrap();
1374        let config = C2Config::new(
1375            "127.0.0.1:0".parse().unwrap(),
1376            "safe-token",
1377            vec![waiting.clone(), second],
1378        )
1379        .expect("two waiting nodes are not a duplicate endpoint");
1380        assert_eq!(config.nodes.len(), 2);
1381    }
1382
1383    /// A node that waits for a call the relay never listens for is a
1384    /// deployment that can never work, so it is refused at startup instead
1385    /// of becoming a node that is silently offline forever.
1386    #[test]
1387    fn waiting_for_a_call_requires_somewhere_to_be_called() {
1388        let waiting = node("accept").unwrap();
1389        let config =
1390            C2Config::new("127.0.0.1:0".parse().unwrap(), "safe-token", vec![waiting]).unwrap();
1391        assert!(matches!(
1392            config.validate_call_home(),
1393            Err(C2ConfigError::CallHomeWithoutListener(_)),
1394        ));
1395        let config = config.with_node_listen("127.0.0.1:48200".parse().unwrap()).unwrap();
1396        assert!(config.validate_call_home().is_ok());
1397    }
1398
1399    /// Call-home may bind loopback or a concrete C2 WireGuard tunnel address.
1400    /// Unspecified (`0.0.0.0`) and port 0 stay refused so plaintext frames do
1401    /// not land on every interface.
1402    #[test]
1403    fn the_call_home_listener_refuses_unspecified_and_accepts_tunnel() {
1404        let waiting = node("accept").unwrap();
1405        let config =
1406            C2Config::new("127.0.0.1:9000".parse().unwrap(), "safe-token", vec![waiting]).unwrap();
1407        for refused in ["0.0.0.0:48200", "127.0.0.1:0", "[::]:48200"] {
1408            assert!(
1409                matches!(
1410                    config.clone().with_node_listen(refused.parse().unwrap()),
1411                    Err(C2ConfigError::NonLoopbackNodeListen(_)),
1412                ),
1413                "accepted {refused}",
1414            );
1415        }
1416        let tunnel: SocketAddr = "10.88.1.2:27442".parse().unwrap();
1417        let accepted = config.clone().with_node_listen(tunnel).unwrap();
1418        assert_eq!(accepted.node_listen, Some(tunnel));
1419        // Sharing the API's own address would mean HTTP and node frames
1420        // arriving on one socket; neither parser would survive the other.
1421        assert!(matches!(
1422            config.with_node_listen("127.0.0.1:9000".parse().unwrap()),
1423            Err(C2ConfigError::NodeListenConflict),
1424        ));
1425    }
1426
1427    #[test]
1428    fn ssh_forwarded_loopback_route_is_strict_canonical_and_control_stays_local() {
1429        let ipv4 = node("tcp://127.0.0.1:48100").unwrap();
1430        assert_eq!(ipv4.endpoint, "tcp://127.0.0.1:48100");
1431        assert_eq!(ipv4.route, C2NodeRoute::SshForwardedLoopback("127.0.0.1:48100".parse().unwrap()));
1432        assert_eq!(ipv4.transport_label(), "ssh-forwarded-loopback");
1433
1434        let ipv6 = node("tcp://[0:0:0:0:0:0:0:1]:48100").unwrap();
1435        assert_eq!(ipv6.endpoint, "tcp://[::1]:48100");
1436        assert_eq!(ipv6.transport_label(), "ssh-forwarded-loopback");
1437
1438        for invalid in [
1439            "tcp://localhost:48100",
1440            "tcp://127.0.0.2:48100",
1441            "tcp://0.0.0.0:48100",
1442            "tcp://127.0.0.1:0",
1443            "tcp://user@127.0.0.1:48100",
1444            "tcp://127.0.0.1:48100/path",
1445            "TCP://127.0.0.1:48100",
1446        ] {
1447            assert!(matches!(node(invalid), Err(C2ConfigError::InvalidEndpoint(_))), "accepted {invalid}");
1448        }
1449
1450        assert!(matches!(
1451            C2Config::new(
1452                "127.0.0.1:0".parse().unwrap(),
1453                "safe-token",
1454                vec![ipv4.clone(), C2NodeConfig::new(
1455                    NodeId::new("duplicate-route").unwrap(),
1456                    "tcp://127.0.0.1:48100",
1457                    "safe-token",
1458                ).unwrap()],
1459            ),
1460            Err(C2ConfigError::DuplicateEndpoint)
1461        ));
1462        assert!(matches!(
1463            C2Config::new("127.0.0.1:0".parse().unwrap(), "safe-token", vec![ipv4])
1464                .unwrap()
1465                .with_control_endpoint("tcp://127.0.0.1:48101"),
1466            Err(C2ConfigError::InvalidControlEndpoint)
1467        ));
1468    }
1469}
1470
1471#[cfg(all(test, windows))]
1472mod tests {
1473    // A2: former g4a relay unit tests removed with node_relay_worker.
1474    // Inventory/HTTP coverage lives in unix e2e and restreamer tests.
1475}
1476
1477#[cfg(test)]
1478mod control_tunnel_listen_tests {
1479    use super::*;
1480
1481    fn config() -> C2Config {
1482        let node = C2NodeConfig::new(
1483            NodeId::new("node-a").unwrap(),
1484            if cfg!(unix) { "/tmp/gate4agent-node-a.sock" } else { r"\\.\pipe\gate4agent-node-a" },
1485            "token-token",
1486        ).unwrap();
1487        C2Config::new("127.0.0.1:18321".parse().unwrap(), "api-token", vec![node]).unwrap()
1488    }
1489
1490    #[test]
1491    fn control_tunnel_rejects_unspecified_and_accepts_tunnel_address() {
1492        let bad: SocketAddr = "0.0.0.0:27441".parse().unwrap();
1493        assert!(matches!(
1494            config().with_control_tunnel_listen(bad),
1495            Err(C2ConfigError::InvalidControlTunnel(_))
1496        ));
1497        let ok: SocketAddr = "10.88.1.2:27441".parse().unwrap();
1498        let config = config().with_control_tunnel_listen(ok).unwrap();
1499        assert_eq!(config.control_tunnel_listen, Some(ok));
1500        assert!(!config.control_endpoint.is_empty());
1501    }
1502}
1503
1504
1505#[cfg(test)]
1506mod opaque_restreamer_tests {
1507    use super::*;
1508    use std::time::Duration;
1509
1510    #[tokio::test]
1511    async fn with_restreamer_config_starts_without_g4a_control_listener() {
1512        // Waiting node: no dial-out. With restreamer, no g4a relay worker either.
1513        let node = C2NodeConfig::new(
1514            NodeId::new("n1").unwrap(),
1515            "accept",
1516            "safe-token",
1517        ).unwrap();
1518        let node_up = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
1519        let drain = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
1520        let sock = std::env::temp_dir().join(format!("c2-opaque-{}.sock", std::process::id()));
1521        let _ = std::fs::remove_file(&sock);
1522        let config = C2Config::new("127.0.0.1:0".parse().unwrap(), "opaque-token-01234567", vec![node])
1523            .unwrap()
1524            .with_control_endpoint(sock.to_string_lossy())
1525            .unwrap()
1526            .with_restreamer(node_up, drain, "drain-token-0123456789ab")
1527            .unwrap()
1528            .with_timings(C2Timings {
1529                attempt_deadline: Duration::from_millis(50),
1530                ..C2Timings::default()
1531            });
1532        let running = C2Running::start(config).await.unwrap();
1533        tokio::time::sleep(Duration::from_millis(80)).await;
1534        assert!(!sock.exists(), "C2 must not bind the legacy operator control socket");
1535        let client = crate::restreamer::DrainClient::new(drain, "drain-token-0123456789ab");
1536        let mut session = client.connect().await.expect("drain connect");
1537        session.open("n1", 1, hatchery_core::ChannelKind::G4aControl).await.unwrap();
1538        session.resume(0).await.unwrap();
1539        running.shutdown_handle().shutdown();
1540        let _ = running.wait().await;
1541        let _ = std::fs::remove_file(&sock);
1542    }
1543}