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
494
495fn load_restreamer_keys_from_env() -> Result<Option<crate::restreamer::RestreamerKeys>, C2Error> {
496    use crate::restreamer::{PublicKeyBytes, RestreamerKeys};
497    use std::path::PathBuf;
498    let seed_path = match std::env::var_os("HATCHERY_RESTREAMER_C2_SEED_FILE") {
499        Some(p) => PathBuf::from(p),
500        None => return Ok(None),
501    };
502    let recip_b64 = match std::env::var("HATCHERY_RESTREAMER_RECIPIENT_PUBLIC_B64") {
503        Ok(v) if !v.trim().is_empty() => v,
504        _ => return Ok(None),
505    };
506    let seed_bytes = std::fs::read(&seed_path).map_err(|e| {
507        C2Error::Api(std::io::Error::other(format!(
508            "HATCHERY_RESTREAMER_C2_SEED_FILE {}: {e}",
509            seed_path.display()
510        )))
511    })?;
512    if seed_bytes.len() != 32 {
513        return Err(C2Error::Api(std::io::Error::other(
514            "HATCHERY_RESTREAMER_C2_SEED_FILE must be exactly 32 bytes",
515        )));
516    }
517    let mut seed = [0u8; 32];
518    seed.copy_from_slice(&seed_bytes);
519    let recip = decode_b64_32(&recip_b64).map_err(|e| {
520        C2Error::Api(std::io::Error::other(format!(
521            "HATCHERY_RESTREAMER_RECIPIENT_PUBLIC_B64: {e}"
522        )))
523    })?;
524    Ok(Some(RestreamerKeys::from_seeds(seed, recip)))
525}
526
527fn decode_b64_32(text: &str) -> Result<[u8; 32], String> {
528    use base64::Engine;
529    let bytes = base64::engine::general_purpose::STANDARD
530        .decode(text.trim())
531        .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(text.trim()))
532        .map_err(|e| e.to_string())?;
533    if bytes.len() != 32 {
534        return Err(format!("expected 32 bytes, got {}", bytes.len()));
535    }
536    let mut out = [0u8; 32];
537    out.copy_from_slice(&bytes);
538    Ok(out)
539}
540
541impl C2Running {
542    pub async fn start(config: C2Config) -> Result<Self, C2Error> {
543        prepare_default_control_parent(&config.control_endpoint)?;
544        let listener = TcpListener::bind(config.api_listen).await?;
545        let api_addr = listener.local_addr()?;
546        let (shutdown_tx, shutdown_rx) = watch::channel(false);
547        let shutdown = C2ShutdownHandle { shutdown: shutdown_tx };
548        let task = tokio::spawn(run_bound(config, listener, shutdown_rx));
549        Ok(Self { api_addr, shutdown, task: Some(task) })
550    }
551
552    pub fn api_addr(&self) -> SocketAddr { self.api_addr }
553    pub fn shutdown_handle(&self) -> C2ShutdownHandle { self.shutdown.clone() }
554    pub async fn wait(mut self) -> Result<(), C2Error> {
555        self.task.take().expect("C2 task is present").await?
556    }
557}
558
559#[cfg(windows)]
560fn prepare_default_control_parent(_endpoint: &str) -> io::Result<()> { Ok(()) }
561
562#[cfg(unix)]
563fn prepare_default_control_parent(endpoint: &str) -> io::Result<()> {
564    let default = default_c2_control_endpoint()
565        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
566    if endpoint != default { return Ok(()); }
567    let parent = Path::new(endpoint).parent().ok_or_else(|| {
568        io::Error::new(io::ErrorKind::InvalidInput, "default C2 endpoint has no parent")
569    })?;
570    std::fs::create_dir_all(parent)?;
571    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
572}
573
574impl Drop for C2Running {
575    fn drop(&mut self) {
576        self.shutdown.shutdown();
577        if let Some(task) = self.task.take() { task.abort(); }
578    }
579}
580
581/// A socket handed from the call-home listener to a node's relay worker, with the
582/// per-connection session token when the node authenticated by signature.
583type CallHomeSocket = (TcpStream, Option<String>);
584type CallHomeRoutes = Arc<Mutex<BTreeMap<NodeId, mpsc::Sender<CallHomeSocket>>>>;
585
586struct Spawner {
587    timings: C2Timings,
588    relay_senders: RelayRegistry,
589    call_home_routes: CallHomeRoutes,
590    ingress: mpsc::Sender<Attempt>,
591}
592
593impl Spawner {
594    /// Presence worker for a runtime-enrolled node (call-home liveness only; no g4a).
595    fn spawn_enrolled(&self, node_id: &NodeId, shutdown: &watch::Receiver<bool>, tasks: &mut JoinSet<io::Result<()>>) {
596        if self.relay_senders.snapshot().contains_key(node_id) {
597            return;
598        }
599        let placeholder = "0".repeat(64);
600        let Ok(node) = C2NodeConfig::new(node_id.clone(), "accept", placeholder) else { return };
601        let (force_tx, force_rx) = watch::channel(0_u64);
602        let (call_home_tx, call_home_rx) = mpsc::channel(1);
603        self.call_home_routes.lock().expect("routes lock").insert(node_id.clone(), call_home_tx);
604        self.relay_senders.insert(node_id.clone(), RelayEndpoint { force_disconnect: force_tx });
605        tasks.spawn(node_presence_worker(
606            node,
607            self.timings,
608            force_rx,
609            Some(call_home_rx),
610            self.ingress.clone(),
611            shutdown.clone(),
612        ));
613        tracing::info!(%node_id, "presence worker created for enrolled node");
614    }
615}
616
617async fn run_bound(config: C2Config, listener: TcpListener, mut shutdown: watch::Receiver<bool>) -> Result<(), C2Error> {
618    let now = unix_ms();
619    let nodes = config.nodes.iter().map(|node| {
620        (node.node_id.clone(), initial_observed_node(node))
621    }).collect();
622    let initial = Arc::new(StatusResponse { api_version: C2_API_VERSION, ready: config.nodes.is_empty(), observed_at_unix_ms: now, nodes });
623    let (status_tx, status_rx) = watch::channel(initial);
624    let (ingress_tx, ingress_rx) = mpsc::channel(config.nodes.len().saturating_mul(2).max(16));
625    let mut tasks = JoinSet::new();
626    let mut relay_senders = BTreeMap::new();
627    let mut presence_receivers = Vec::new();
628    let mut static_routes: BTreeMap<NodeId, mpsc::Sender<CallHomeSocket>> = BTreeMap::new();
629    for node in config.nodes.clone() {
630        let (force_tx, force_rx) = watch::channel(0_u64);
631        relay_senders.insert(node.node_id.clone(), RelayEndpoint { force_disconnect: force_tx });
632        let call_home_rx = if node.route == C2NodeRoute::CallHome {
633            let (call_home_tx, call_home_rx) = mpsc::channel(1);
634            static_routes.insert(node.node_id.clone(), call_home_tx);
635            Some(call_home_rx)
636        } else {
637            None
638        };
639        presence_receivers.push((node, force_rx, call_home_rx));
640    }
641    let static_ids: BTreeSet<NodeId> = static_routes.keys().cloned().collect();
642    let call_home_routes: CallHomeRoutes = Arc::new(Mutex::new(static_routes));
643    let mut relay_senders = RelayRegistry::new(relay_senders);
644    // Enrollment: registry, listener, runtime node adds.
645    let mut enroll_events = None;
646    let mut enroll_service = None;
647    if let Some(enrollment) = &config.enrollment {
648        let (service, events) = crate::enroll_service::EnrollService::start(enrollment)
649            .map_err(|error| C2Error::Api(io::Error::other(error.to_string())))?;
650        let enroll_listener = TcpListener::bind(enrollment.listen).await?;
651        tasks.spawn(crate::enroll_service::serve_enroll(enroll_listener, Arc::clone(&service), shutdown.clone()));
652        relay_senders.gate = Some(Arc::clone(&service) as Arc<dyn crate::enroll_service::CapabilityGate>);
653        enroll_events = Some(events);
654        enroll_service = Some(service);
655    }
656    if let Some(node_listen) = config.node_listen {
657        tasks.spawn(accept_call_home(node_listen, Arc::clone(&call_home_routes), static_ids, enroll_service.clone(), shutdown.clone()));
658    }
659    // A2 restreamer: opaque node uplink + core drain (C2 never dials core).
660    if let (Some(node_listen), Some(drain_listen), Some(core_token)) = (
661        config.restreamer_node_listen,
662        config.restreamer_drain_listen,
663        config.restreamer_core_token.clone(),
664    ) {
665        let journal = Arc::new(crate::restreamer::MemoryJournal::new());
666        let keys = load_restreamer_keys_from_env()?;
667        if keys.is_some() {
668            tracing::info!("A2 restreamer nested keys loaded");
669        } else {
670            tracing::warn!("A2 restreamer nested keys missing; uplink DATA cannot be unwrapped (set HATCHERY_RESTREAMER_C2_SEED_FILE + HATCHERY_RESTREAMER_RECIPIENT_PUBLIC_B64)");
671        }
672        let restreamer = crate::restreamer::Restreamer::new(
673            crate::restreamer::BufferLimits::default(),
674            journal,
675            core_token.clone(),
676            keys,
677        );
678        let cfg = crate::restreamer::RestreamerConfig {
679            node_listen,
680            drain_listen,
681            buffer: crate::restreamer::BufferLimits::default(),
682            core_token,
683        };
684        let rs = restreamer.clone();
685        let sd = shutdown.clone();
686        tasks.spawn(async move {
687            crate::restreamer::serve(cfg, rs, sd)
688                .await
689                .map_err(|error| io::Error::other(error.to_string()))
690        });
691        tracing::info!(%node_listen, %drain_listen, "A2 restreamer listening");
692    }
693    tasks.spawn(inventory_owner(config.timings.fresh_for, ingress_rx, status_tx, shutdown.clone()));
694    tracing::info!("A2: g4a node relays removed; call-home presence only");
695    for (node, force_disconnect, call_home) in presence_receivers {
696        tasks.spawn(node_presence_worker(node, config.timings, force_disconnect, call_home, ingress_tx.clone(), shutdown.clone()));
697    }
698    let spawner = Spawner {
699        timings: config.timings,
700        relay_senders: relay_senders.clone(),
701        call_home_routes: Arc::clone(&call_home_routes),
702        ingress: ingress_tx.clone(),
703    };
704    drop(ingress_tx);
705    tasks.spawn(http_server(listener, config.api_token.clone(), config.timings.http_io_deadline, status_rx.clone(), enroll_service.clone(), shutdown.clone()));
706    // A2: no operator control listener on C2. Core opens the drain and parses g4a.
707    let _ = (config.control_endpoint, config.control_tunnel_listen, status_rx);
708    loop {
709        tokio::select! {
710            changed = shutdown.changed() => {
711                if changed.is_err() || *shutdown.borrow() { break; }
712            }
713            event = async {
714                match enroll_events.as_mut() {
715                    Some(events) => events.recv().await,
716                    None => std::future::pending().await,
717                }
718            } => {
719                match event {
720                    Some(crate::enroll_service::EnrollEvent::Enrolled(id)) => {
721                        if let Ok(node_id) = NodeId::new(id) { spawner.spawn_enrolled(&node_id, &shutdown, &mut tasks); }
722                    }
723                    Some(crate::enroll_service::EnrollEvent::StatusChanged(id, _)) => {
724                        // A node that no longer holds the heartbeat capability is dropped.
725                        if let Ok(node_id) = NodeId::new(id) {
726                            if !relay_senders.allows(&node_id, "heartbeat") {
727                                if let Some(endpoint) = relay_senders.snapshot().get(&node_id) {
728                                    endpoint.force_disconnect.send_modify(|generation| *generation = generation.wrapping_add(1));
729                                }
730                            }
731                        }
732                    }
733                    Some(crate::enroll_service::EnrollEvent::PolicyChanged) => {
734                        if let Some(service) = &enroll_service {
735                            for id in service.nodes_without("heartbeat") {
736                                if let Ok(node_id) = NodeId::new(id) {
737                                    if let Some(endpoint) = relay_senders.snapshot().get(&node_id) {
738                                        endpoint.force_disconnect.send_modify(|generation| *generation = generation.wrapping_add(1));
739                                    }
740                                }
741                            }
742                        }
743                    }
744                    None => enroll_events = None,
745                }
746            }
747            result = tasks.join_next() => {
748                match result {
749                    Some(Ok(Ok(()))) if *shutdown.borrow() => break,
750                    Some(Ok(Ok(()))) => return Err(C2Error::Api(io::Error::new(io::ErrorKind::Other, "C2 task exited unexpectedly"))),
751                    Some(Ok(Err(error))) => return Err(C2Error::Api(error)),
752                    Some(Err(error)) => return Err(C2Error::Task(error)),
753                    None => break,
754                }
755            }
756        }
757    }
758    tasks.shutdown().await;
759    Ok(())
760}
761
762fn initial_enrolled_node() -> ObservedNode {
763    ObservedNode {
764        endpoint: "call-home".to_owned(), transport_label: "call-home".to_owned(),
765        transport: NodeTransportState::Offline, freshness: NodeFreshness::Unavailable,
766        cursor: None, inventory: None, machine: None, last_attempt_unix_ms: None, last_success_unix_ms: None,
767        consecutive_failures: 0, last_error: None, gaps: Vec::new(), gaps_truncated: 0,
768    }
769}
770
771fn initial_observed_node(node: &C2NodeConfig) -> ObservedNode {
772    ObservedNode {
773        endpoint: node.endpoint.clone(), transport_label: node.transport_label().to_owned(),
774        transport: NodeTransportState::Offline, freshness: NodeFreshness::Unavailable,
775        cursor: None, inventory: None, machine: None, last_attempt_unix_ms: None, last_success_unix_ms: None,
776        consecutive_failures: 0, last_error: None, gaps: Vec::new(), gaps_truncated: 0,
777    }
778}
779
780#[cfg(windows)]
781fn local_transport_label() -> &'static str { "windows-named-pipe" }
782
783#[cfg(unix)]
784fn local_transport_label() -> &'static str { "unix-domain-socket" }
785
786#[derive(Clone, Default)]
787struct ProviderContractManifest {
788    provider_contracts: Vec<ProviderContractSupport>,
789    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
790}
791
792impl ProviderContractManifest {
793    fn from_compatibility(compatibility: Option<&NegotiatedNodeCompatibility>) -> Self {
794        let Some(compatibility) = compatibility.filter(|compatibility| {
795            compatibility.capabilities.iter().any(|capability| {
796                capability.as_str() == C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY
797            })
798        }) else {
799            return Self::default();
800        };
801        Self {
802            provider_contracts: compatibility.provider_contracts.clone(),
803            provider_adapter_contracts: compatibility.provider_adapter_contracts.clone(),
804        }
805    }
806}
807
808enum AttemptResult {
809    Connected {
810        cursor: NodeCursor,
811        snapshot: NodeSnapshot,
812        gaps: Vec<GapKind>,
813        provider_contract_manifest: ProviderContractManifest,
814    },
815    Success { cursor: NodeCursor, snapshot: NodeSnapshot, gaps: Vec<GapKind> },
816    Cursor {
817        cursor: NodeCursor,
818        gaps: Vec<GapKind>,
819        managed_worktree_events: Vec<NodeEvent>,
820    },
821    Failure { error: SanitizedError, hard: bool },
822}
823
824struct Attempt { node_id: NodeId, at_unix_ms: u64, result: AttemptResult }
825
826/// Accepts node connections and hands each one to the relay worker for
827/// whichever node it says it is.
828///
829/// This is the only place in the relay that learns a node's identity from
830/// the wire rather than from configuration, and it is careful about what
831/// that means: the announced name SELECTS a worker and nothing more. The
832/// worker then runs the same mutual challenge-response it runs on a
833/// dialled connection, against the token configured for that node, so a
834/// caller that announced a name it cannot prove gets no further than the
835/// next frame.
836///
837/// A socket for an unknown node, an unreadable preface, or a node whose
838/// slot is already full is dropped with a line saying which -- never
839/// silently, because "the node is calling but nothing happens" is exactly
840/// the failure that is impossible to diagnose from the node's end.
841async fn accept_call_home(
842    listen: SocketAddr,
843    routes: CallHomeRoutes,
844    static_ids: BTreeSet<NodeId>,
845    enroll: Option<Arc<crate::enroll_service::EnrollService>>,
846    mut shutdown: watch::Receiver<bool>,
847) -> io::Result<()> {
848    let listener = TcpListener::bind(listen).await?;
849    tracing::info!(%listen, waiting_nodes = routes.lock().expect("routes lock").len(), "call-home listener bound");
850    loop {
851        if *shutdown.borrow() {
852            return Ok(());
853        }
854        let (stream, peer) = tokio::select! {
855            accepted = listener.accept() => accepted?,
856            changed = shutdown.changed() => {
857                if changed.is_err() || *shutdown.borrow() { return Ok(()); }
858                continue;
859            }
860        };
861        let routes = Arc::clone(&routes);
862        let static_ids = static_ids.clone();
863        let enroll = enroll.clone();
864        // Off the accept loop: the preface has its own deadline, and one
865        // peer that connects and then says nothing must not stop every
866        // other node from being accepted meanwhile.
867        tokio::spawn(async move {
868            let mut stream = stream;
869            let _ = stream.set_nodelay(true);
870            let node_id = match read_call_home_announce(&mut stream).await {
871                Ok(node_id) => node_id,
872                Err(error) => {
873                    tracing::warn!(%peer, cause = %error, "call-home connection rejected");
874                    return;
875                }
876            };
877            // Token nodes (configured with --node) keep the original path. Enrolled nodes
878            // prove their identity by signing a challenge; the exchange yields a
879            // per-connection session token for the unchanged wire handshake.
880            let mut session_token = None;
881            if !static_ids.contains(&node_id) {
882                let Some(enroll) = enroll.as_ref() else {
883                    tracing::warn!(%peer, %node_id, "call-home connection named a node this relay does not wait for");
884                    return;
885                };
886                match enroll.signed_call_home(&mut stream, node_id.as_str()).await {
887                    Ok(token) => session_token = Some(token),
888                    Err(cause) => {
889                        tracing::warn!(%peer, %node_id, %cause, "signed call-home refused");
890                        return;
891                    }
892                }
893            }
894            let sender = routes.lock().expect("routes lock").get(&node_id).cloned();
895            let Some(sender) = sender else {
896                tracing::warn!(
897                    %peer,
898                    %node_id,
899                    "call-home connection named a node this relay does not wait for",
900                );
901                return;
902            };
903            match sender.try_send((stream, session_token)) {
904                Ok(()) => tracing::info!(%peer, %node_id, "node called home"),
905                Err(mpsc::error::TrySendError::Full(_)) => tracing::warn!(
906                    %peer,
907                    %node_id,
908                    "node called home while its previous connection is still being taken up",
909                ),
910                Err(mpsc::error::TrySendError::Closed(_)) => tracing::warn!(
911                    %peer,
912                    %node_id,
913                    "node called home but its relay worker is gone",
914                ),
915            }
916        });
917    }
918}
919
920/// Blocks until this node calls in, or shutdown -- with no deadline of its
921/// own on purpose.
922///
923/// A dialled connection either answers within `attempt_deadline` or has
924/// failed, and treating a slow answer as a failure is right there. Waiting
925/// for a node to call is the opposite: a node that has not called yet has
926/// not failed at anything, and it may be minutes from starting. Putting
927/// that wait under the attempt deadline would turn an idle relay into a
928/// stream of "connection deadline exceeded" warnings about nodes that are
929/// merely not running. The handshake that FOLLOWS is deadlined normally --
930/// a peer that connects and then stalls is a real failure.
931async fn await_call_home(
932    receiver: Option<&mut mpsc::Receiver<CallHomeSocket>>,
933    shutdown: &mut watch::Receiver<bool>,
934) -> Option<CallHomeSocket> {
935    let receiver = receiver?;
936    loop {
937        if *shutdown.borrow() {
938            return None;
939        }
940        tokio::select! {
941            stream = receiver.recv() => return stream,
942            changed = shutdown.changed() => {
943                if changed.is_err() || *shutdown.borrow() { return None; }
944            }
945        }
946    }
947}
948
949async fn node_presence_worker(
950    node: C2NodeConfig,
951    timings: C2Timings,
952    mut force_disconnect: watch::Receiver<u64>,
953    mut call_home: Option<mpsc::Receiver<CallHomeSocket>>,
954    ingress: mpsc::Sender<Attempt>,
955    mut shutdown: watch::Receiver<bool>,
956) -> io::Result<()> {
957    // A2: C2 never speaks g4a. Call-home only proves the node can reach edge;
958    // spawn/inventory go through the opaque restreamer to core.
959    let _ = timings;
960    loop {
961        if *shutdown.borrow() { return Ok(()); }
962        let adopted = if node.route == C2NodeRoute::CallHome {
963            match await_call_home(call_home.as_mut(), &mut shutdown).await {
964                Some((stream, _token)) => stream,
965                None => return Ok(()),
966            }
967        } else {
968            // Dialled/local routes are unused under A2 opaque restreamer.
969            tokio::select! {
970                changed = shutdown.changed() => {
971                    if changed.is_err() || *shutdown.borrow() { return Ok(()); }
972                }
973                _ = force_disconnect.changed() => {}
974                _ = tokio::time::sleep(Duration::from_secs(60)) => {}
975            }
976            continue;
977        };
978        let incarnation_id = gate4agent_node_wire::random_incarnation_id()
979            .unwrap_or_else(|_| NodeIncarnationId::from_bytes([0u8; 16]));
980        let cursor = NodeCursor { incarnation_id, sequence: 0 };
981        let snapshot = NodeSnapshot {
982            node_id: node.node_id.clone(),
983            enabled_providers: Vec::new(),
984            provider_runtime_statuses: Default::default(),
985            workspaces: Vec::new(),
986            session_records: Vec::new(),
987            managed_worktrees: Vec::new(),
988            launch_inventory: None,
989            agent_progress: Vec::new(),
990        };
991        ingress_attempt(&ingress, &node.node_id, AttemptResult::Connected {
992            cursor,
993            snapshot,
994            gaps: Vec::new(),
995            provider_contract_manifest: ProviderContractManifest::default(),
996        }).await?;
997        tracing::info!(node_id = %node.node_id, "node present on call-home (no g4a on C2)");
998        let mut stream = adopted;
999        let mut buf = [0u8; 256];
1000        let drop_reason = loop {
1001            tokio::select! {
1002                changed = force_disconnect.changed() => {
1003                    let _ = changed;
1004                    break "forced disconnect";
1005                }
1006                changed = shutdown.changed() => {
1007                    if changed.is_err() || *shutdown.borrow() {
1008                        return Ok(());
1009                    }
1010                }
1011                read = tokio::io::AsyncReadExt::read(&mut stream, &mut buf) => {
1012                    match read {
1013                        Ok(0) => break "call-home closed",
1014                        Ok(_) => {}
1015                        Err(_) => break "call-home read error",
1016                    }
1017                }
1018            }
1019        };
1020        tracing::info!(node_id = %node.node_id, reason = drop_reason, "node presence ended");
1021        ingress_attempt(&ingress, &node.node_id, AttemptResult::Failure {
1022            error: SanitizedError { category: C2ErrorCategory::Transport, message: drop_reason.to_owned() },
1023            hard: false,
1024        }).await?;
1025    }
1026}
1027
1028async fn ingress_attempt(
1029    ingress: &mpsc::Sender<Attempt>,
1030    node_id: &NodeId,
1031    result: AttemptResult,
1032) -> io::Result<()> {
1033    ingress.send(Attempt { node_id: node_id.clone(), at_unix_ms: unix_ms(), result }).await
1034        .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "inventory owner closed"))
1035}
1036
1037async fn inventory_owner(
1038    fresh_for: Duration, mut ingress: mpsc::Receiver<Attempt>,
1039    status: watch::Sender<Arc<StatusResponse>>, mut shutdown: watch::Receiver<bool>,
1040) -> io::Result<()> {
1041    let mut current = (**status.borrow()).clone();
1042    let mut attempted = BTreeSet::new();
1043    loop {
1044        tokio::select! {
1045            attempt = ingress.recv() => {
1046                let Some(attempt) = attempt else { return Ok(()); };
1047                attempted.insert(attempt.node_id.clone());
1048                let node = current.nodes.entry(attempt.node_id.clone()).or_insert_with(initial_enrolled_node);
1049                node.last_attempt_unix_ms = Some(attempt.at_unix_ms);
1050                match attempt.result {
1051                    AttemptResult::Connected {
1052                        cursor,
1053                        snapshot,
1054                        gaps,
1055                        provider_contract_manifest,
1056                    } => {
1057                        let previous = node.cursor;
1058                        let mut inventory = SlimNodeInventory::from_snapshot(&snapshot);
1059                        inventory.provider_contracts =
1060                            provider_contract_manifest.provider_contracts;
1061                        inventory.provider_adapter_contracts =
1062                            provider_contract_manifest.provider_adapter_contracts;
1063                        node.transport = NodeTransportState::Online;
1064                        node.freshness = NodeFreshness::Fresh;
1065                        node.cursor = Some(cursor);
1066                        node.inventory = Some(inventory);
1067                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1068                        node.consecutive_failures = 0;
1069                        node.last_error = None;
1070                        for kind in gaps {
1071                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1072                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1073                        }
1074                    }
1075                    AttemptResult::Success { cursor, snapshot, gaps } => {
1076                        let previous = node.cursor;
1077                        let provider_contract_manifest = node.inventory.as_ref().map(|inventory| {
1078                            ProviderContractManifest {
1079                                provider_contracts: inventory.provider_contracts.clone(),
1080                                provider_adapter_contracts: inventory.provider_adapter_contracts.clone(),
1081                            }
1082                        }).unwrap_or_default();
1083                        let mut inventory = SlimNodeInventory::from_snapshot(&snapshot);
1084                        inventory.provider_contracts =
1085                            provider_contract_manifest.provider_contracts;
1086                        inventory.provider_adapter_contracts =
1087                            provider_contract_manifest.provider_adapter_contracts;
1088                        node.transport = NodeTransportState::Online;
1089                        node.freshness = NodeFreshness::Fresh;
1090                        node.cursor = Some(cursor);
1091                        node.inventory = Some(inventory);
1092                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1093                        node.consecutive_failures = 0;
1094                        node.last_error = None;
1095                        for kind in gaps {
1096                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1097                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1098                        }
1099                    }
1100                    AttemptResult::Cursor {
1101                        cursor,
1102                        gaps,
1103                        managed_worktree_events,
1104                    } => {
1105                        let previous = node.cursor;
1106                        let incarnation_changed = previous.is_some_and(|previous| {
1107                            previous.incarnation_id != cursor.incarnation_id
1108                        });
1109                        apply_managed_worktree_cursor(
1110                            node.inventory.as_mut(),
1111                            incarnation_changed,
1112                            &managed_worktree_events,
1113                        );
1114                        node.transport = NodeTransportState::Online;
1115                        node.freshness = NodeFreshness::Fresh;
1116                        node.cursor = Some(cursor);
1117                        node.last_success_unix_ms = Some(attempt.at_unix_ms);
1118                        node.consecutive_failures = 0;
1119                        node.last_error = None;
1120                        for kind in gaps {
1121                            if node.gaps.len() == MAX_C2_GAPS_PER_NODE { node.gaps.remove(0); node.gaps_truncated += 1; }
1122                            node.gaps.push(NodeGap { kind, detected_at_unix_ms: attempt.at_unix_ms, previous, observed: cursor });
1123                        }
1124                    }
1125                    AttemptResult::Failure { error, hard } => {
1126                        node.consecutive_failures = node.consecutive_failures.saturating_add(1);
1127                        node.transport = if hard || node.consecutive_failures >= 5 { NodeTransportState::Parked } else { NodeTransportState::Offline };
1128                        node.last_error = Some(error);
1129                    }
1130                }
1131                current.ready = current.nodes.keys().all(|id| attempted.contains(id));
1132                refresh_freshness(&mut current, fresh_for);
1133                current.observed_at_unix_ms = unix_ms();
1134                status.send_replace(Arc::new(current.clone()));
1135            }
1136            _ = sleep(Duration::from_millis(250)) => {
1137                refresh_freshness(&mut current, fresh_for);
1138                current.observed_at_unix_ms = unix_ms();
1139                status.send_replace(Arc::new(current.clone()));
1140            }
1141            changed = shutdown.changed() => if changed.is_err() || *shutdown.borrow() { return Ok(()); },
1142        }
1143    }
1144}
1145
1146fn apply_managed_worktree_cursor(
1147    inventory: Option<&mut SlimNodeInventory>,
1148    incarnation_changed: bool,
1149    events: &[NodeEvent],
1150) {
1151    let Some(inventory) = inventory else { return; };
1152    if incarnation_changed {
1153        inventory.provider_runtime_statuses.clear();
1154        inventory.managed_worktrees.clear();
1155        inventory.managed_worktree_count = 0;
1156        inventory.managed_worktrees_truncated = false;
1157        return;
1158    }
1159    for event in events {
1160        match event {
1161            NodeEvent::ManagedWorktreeUpserted { lease } => {
1162                inventory.managed_worktrees.retain(|existing| {
1163                    existing.lease_id != lease.lease_id
1164                        && existing.workspace_id != lease.workspace_id
1165                });
1166                inventory.managed_worktrees.push(lease.clone());
1167                inventory.managed_worktrees.sort_by(|left, right| {
1168                    left.lease_id.cmp(&right.lease_id)
1169                });
1170                inventory.managed_worktree_count = inventory.managed_worktrees.len();
1171                inventory.managed_worktrees.truncate(
1172                    crate::protocol::MAX_C2_MANAGED_WORKTREES_PER_NODE,
1173                );
1174                inventory.managed_worktrees_truncated =
1175                    inventory.managed_worktrees.len() < inventory.managed_worktree_count;
1176            }
1177            NodeEvent::ManagedWorktreeRemoved { lease_id } => {
1178                let before = inventory.managed_worktrees.len();
1179                inventory
1180                    .managed_worktrees
1181                    .retain(|lease| &lease.lease_id != lease_id);
1182                if inventory.managed_worktrees.len() < before
1183                    || inventory.managed_worktrees_truncated
1184                {
1185                    inventory.managed_worktree_count =
1186                        inventory.managed_worktree_count.saturating_sub(1);
1187                }
1188                inventory.managed_worktrees_truncated =
1189                    inventory.managed_worktrees.len() < inventory.managed_worktree_count;
1190            }
1191            _ => {}
1192        }
1193    }
1194}
1195
1196fn refresh_freshness(status: &mut StatusResponse, fresh_for: Duration) {
1197    let now = unix_ms();
1198    let fresh_ms = fresh_for.as_millis().min(u64::MAX as u128) as u64;
1199    for node in status.nodes.values_mut() {
1200        node.freshness = match node.last_success_unix_ms {
1201            None => NodeFreshness::Unavailable,
1202            Some(last) if now.saturating_sub(last) <= fresh_ms => NodeFreshness::Fresh,
1203            Some(_) => NodeFreshness::Stale,
1204        };
1205    }
1206}
1207
1208async fn http_server(
1209    listener: TcpListener, token: String, io_deadline: Duration,
1210    status: watch::Receiver<Arc<StatusResponse>>,
1211    enroll: Option<Arc<crate::enroll_service::EnrollService>>,
1212    mut shutdown: watch::Receiver<bool>,
1213) -> io::Result<()> {
1214    let permits = Arc::new(Semaphore::new(MAX_HTTP_CONNECTIONS));
1215    let mut connections = JoinSet::new();
1216    loop {
1217        tokio::select! {
1218            accepted = listener.accept() => {
1219                let (stream, _) = accepted?;
1220                let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { drop(stream); continue; };
1221                let token = token.clone();
1222                let status = status.clone();
1223                let enroll = enroll.clone();
1224                connections.spawn(async move { let _permit = permit; let _ = serve_http(stream, &token, io_deadline, &status, enroll.as_deref()).await; });
1225            }
1226            changed = shutdown.changed() => if changed.is_err() || *shutdown.borrow() { break; },
1227        }
1228        while let Some(result) = connections.try_join_next() { result.map_err(io::Error::other)?; }
1229    }
1230    connections.shutdown().await;
1231    Ok(())
1232}
1233
1234async fn serve_http(mut stream: TcpStream, token: &str, deadline: Duration, status: &watch::Receiver<Arc<StatusResponse>>, enroll: Option<&crate::enroll_service::EnrollService>) -> io::Result<()> {
1235    let request = match timeout(deadline, read_request(&mut stream)).await {
1236        Ok(Ok(request)) => request,
1237        Ok(Err(ReadError::TooLarge)) => return write_response(&mut stream, deadline, Response::plain(413, "Payload Too Large")).await,
1238        Ok(Err(ReadError::Io(error))) => return Err(error),
1239        _ => return Ok(()),
1240    };
1241    let response = route(request, token, status.borrow().as_ref(), enroll);
1242    write_response(&mut stream, deadline, response).await
1243}
1244
1245struct Request { method: String, path: String, authorization: Option<String>, body: Vec<u8> }
1246enum ReadError { Closed, Invalid, TooLarge, Io(io::Error) }
1247
1248async fn read_request(stream: &mut TcpStream) -> Result<Request, ReadError> {
1249    let mut bytes = Vec::with_capacity(1024);
1250    let mut chunk = [0_u8; 1024];
1251    let header_end = loop {
1252        let count = stream.read(&mut chunk).await.map_err(ReadError::Io)?;
1253        if count == 0 { return Err(ReadError::Closed); }
1254        if bytes.len().saturating_add(count) > HEADER_LIMIT_BYTES + OPERATOR_BODY_LIMIT_BYTES { return Err(ReadError::TooLarge); }
1255        bytes.extend_from_slice(&chunk[..count]);
1256        if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { break position + 4; }
1257    };
1258    let text = std::str::from_utf8(&bytes[..header_end]).map_err(|_| ReadError::Invalid)?;
1259    let mut lines = text.split("\r\n");
1260    let mut first = lines.next().ok_or(ReadError::Invalid)?.split_whitespace();
1261    let method = first.next().ok_or(ReadError::Invalid)?;
1262    let path = first.next().ok_or(ReadError::Invalid)?;
1263    let version = first.next().ok_or(ReadError::Invalid)?;
1264    if first.next().is_some() || !version.starts_with("HTTP/1.") || !path.starts_with('/') { return Err(ReadError::Invalid); }
1265    let mut authorization = None;
1266    let mut content_length = 0_usize;
1267    for line in lines {
1268        if line.is_empty() { break; }
1269        let (name, value) = line.split_once(':').ok_or(ReadError::Invalid)?;
1270        if name.eq_ignore_ascii_case("authorization") {
1271            if authorization.is_some() { return Err(ReadError::Invalid); }
1272            authorization = Some(value.trim().to_owned());
1273        } else if name.eq_ignore_ascii_case("content-length") {
1274            content_length = value.trim().parse().map_err(|_| ReadError::Invalid)?;
1275        }
1276    }
1277    if content_length > OPERATOR_BODY_LIMIT_BYTES { return Err(ReadError::TooLarge); }
1278    let mut body = bytes[header_end..].to_vec();
1279    while body.len() < content_length {
1280        let count = stream.read(&mut chunk).await.map_err(ReadError::Io)?;
1281        if count == 0 { return Err(ReadError::Closed); }
1282        body.extend_from_slice(&chunk[..count]);
1283    }
1284    body.truncate(content_length);
1285    Ok(Request { method: method.to_owned(), path: path.to_owned(), authorization, body })
1286}
1287
1288fn route(request: Request, token: &str, status: &StatusResponse, enroll: Option<&crate::enroll_service::EnrollService>) -> Response {
1289    let path = request.path.split_once('?').map_or(request.path.as_str(), |pair| pair.0);
1290    // Operator enrollment endpoints: bearer-authenticated, loopback API only.
1291    if path == "/v1/enroll" || path.starts_with("/v1/enroll/") {
1292        let Some(service) = enroll else { return Response::plain(404, "Not Found"); };
1293        if !authorized(request.authorization.as_deref(), token) { return Response::plain(401, "Unauthorized").authenticate(); }
1294        let reply = service.handle_operator(&request.method, path, &request.body);
1295        return Response::raw_json(reply.status, reply.body);
1296    }
1297    if request.method != "GET" { return Response::plain(405, "Method Not Allowed").allow_get(); }
1298    match path {
1299        "/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() }),
1300        "/ready" => {
1301            let online_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Online).count();
1302            let offline_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Offline).count();
1303            let parked_nodes = status.nodes.values().filter(|node| node.transport == NodeTransportState::Parked).count();
1304            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 };
1305            Response::json(if status.ready { 200 } else { 503 }, &body)
1306        }
1307        "/status" => {
1308            if !authorized(request.authorization.as_deref(), token) { Response::plain(401, "Unauthorized").authenticate() }
1309            else {
1310                // Nodes whose status does not hold `attach` show liveness only.
1311                let mut shown = status.clone();
1312                if let Some(service) = enroll {
1313                    for (id, node) in shown.nodes.iter_mut() {
1314                        if service.allows(id.as_str(), "attach") == Some(false) { node.inventory = None; }
1315                    }
1316                }
1317                Response::json(200, &shown)
1318            }
1319        }
1320        _ => Response::plain(404, "Not Found"),
1321    }
1322}
1323
1324fn authorized(header: Option<&str>, token: &str) -> bool {
1325    let Some((scheme, candidate)) = header.and_then(|value| value.split_once(' ')) else { return false; };
1326    scheme.eq_ignore_ascii_case("bearer") && constant_time_eq(candidate.as_bytes(), token.as_bytes())
1327}
1328
1329fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
1330    if left.len() != right.len() { return false; }
1331    left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
1332}
1333
1334struct Response { status: u16, reason: &'static str, content_type: &'static str, body: Vec<u8>, headers: Vec<(&'static str, &'static str)> }
1335impl Response {
1336    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() } }
1337    fn json<T: serde::Serialize>(status: u16, value: &T) -> Self {
1338        let body = serde_json::to_vec(value).expect("C2 DTO must serialize");
1339        if body.len() > RESPONSE_BODY_LIMIT_BYTES { return Self::plain(503, "Service Unavailable"); }
1340        let reason = if status == 200 { "OK" } else { "Service Unavailable" };
1341        Self { status, reason, content_type: "application/json", body, headers: Vec::new() }
1342    }
1343    fn raw_json(status: u16, body: Vec<u8>) -> Self {
1344        let reason = match status { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 409 => "Conflict", 429 => "Too Many Requests", _ => "Service Unavailable" };
1345        Self { status, reason, content_type: "application/json", body, headers: Vec::new() }
1346    }
1347    fn allow_get(mut self) -> Self { self.headers.push(("Allow", "GET")); self }
1348    fn authenticate(mut self) -> Self { self.headers.push(("WWW-Authenticate", "Bearer")); self }
1349}
1350
1351async fn write_response(stream: &mut TcpStream, deadline: Duration, response: Response) -> io::Result<()> {
1352    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());
1353    for (name, value) in response.headers { headers.push_str(name); headers.push_str(": "); headers.push_str(value); headers.push_str("\r\n"); }
1354    headers.push_str("\r\n");
1355    timeout(deadline, async { stream.write_all(headers.as_bytes()).await?; stream.write_all(&response.body).await?; stream.shutdown().await }).await
1356        .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "C2 HTTP write timed out"))?
1357}
1358
1359fn unix_ms() -> u64 {
1360    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64
1361}
1362
1363#[cfg(test)]
1364mod endpoint_tests {
1365    use super::*;
1366    use crate::protocol::{C2RelayRoute, C2Topology};
1367
1368    fn node(endpoint: &str) -> Result<C2NodeConfig, C2ConfigError> {
1369        C2NodeConfig::new(NodeId::new("remote-node").unwrap(), endpoint, "safe-token")
1370    }
1371
1372    #[cfg(windows)]
1373    const LOCAL_ENDPOINT: &str = r"\\.\pipe\relay-route-fact";
1374    #[cfg(unix)]
1375    const LOCAL_ENDPOINT: &str = "/tmp/gate4agent-relay-route-fact.sock";
1376
1377    fn projected_route(node: &C2NodeConfig, transport: NodeTransportState) -> C2RelayRoute {
1378        let mut observed = initial_observed_node(node);
1379        observed.transport = transport;
1380        let status = StatusResponse {
1381            api_version: C2_API_VERSION,
1382            ready: false,
1383            observed_at_unix_ms: 1,
1384            nodes: BTreeMap::from([(node.node_id.clone(), observed)]),
1385        };
1386        C2Topology::from_status(&status).nodes[0].relay_route
1387    }
1388
1389    #[test]
1390    fn relay_route_fact_projects_exactly_and_survives_offline_and_parked() {
1391        let local = node(LOCAL_ENDPOINT).unwrap();
1392        let ssh = node("tcp://127.0.0.1:48100").unwrap();
1393
1394        for transport in [
1395            NodeTransportState::Online,
1396            NodeTransportState::Offline,
1397            NodeTransportState::Parked,
1398        ] {
1399            assert_eq!(projected_route(&local, transport), C2RelayRoute::LocalIpc);
1400            assert_eq!(
1401                projected_route(&ssh, transport),
1402                C2RelayRoute::SshForwardedLoopback,
1403            );
1404        }
1405    }
1406
1407    /// `accept` is the assignment that names no address, and the config
1408    /// has to treat it as a different KIND of route rather than as a
1409    /// malformed endpoint: it is how a node that cannot be dialled is
1410    /// declared.
1411    #[test]
1412    fn a_node_that_waits_to_be_called_is_declared_by_name_not_by_address() {
1413        let waiting = node("accept").unwrap();
1414        assert_eq!(waiting.route, C2NodeRoute::CallHome);
1415        assert_eq!(waiting.transport_label(), "call-home");
1416
1417        // Every waiting node carries the same `accept`, so the
1418        // duplicate-endpoint check must not see them as two nodes fighting
1419        // over one socket. Their uniqueness is `node_id`'s job, and that
1420        // check still applies.
1421        let second = C2NodeConfig::new(
1422            NodeId::new("second-waiting-node").unwrap(),
1423            "accept",
1424            "safe-token",
1425        )
1426        .unwrap();
1427        let config = C2Config::new(
1428            "127.0.0.1:0".parse().unwrap(),
1429            "safe-token",
1430            vec![waiting.clone(), second],
1431        )
1432        .expect("two waiting nodes are not a duplicate endpoint");
1433        assert_eq!(config.nodes.len(), 2);
1434    }
1435
1436    /// A node that waits for a call the relay never listens for is a
1437    /// deployment that can never work, so it is refused at startup instead
1438    /// of becoming a node that is silently offline forever.
1439    #[test]
1440    fn waiting_for_a_call_requires_somewhere_to_be_called() {
1441        let waiting = node("accept").unwrap();
1442        let config =
1443            C2Config::new("127.0.0.1:0".parse().unwrap(), "safe-token", vec![waiting]).unwrap();
1444        assert!(matches!(
1445            config.validate_call_home(),
1446            Err(C2ConfigError::CallHomeWithoutListener(_)),
1447        ));
1448        let config = config.with_node_listen("127.0.0.1:48200".parse().unwrap()).unwrap();
1449        assert!(config.validate_call_home().is_ok());
1450    }
1451
1452    /// Call-home may bind loopback or a concrete C2 WireGuard tunnel address.
1453    /// Unspecified (`0.0.0.0`) and port 0 stay refused so plaintext frames do
1454    /// not land on every interface.
1455    #[test]
1456    fn the_call_home_listener_refuses_unspecified_and_accepts_tunnel() {
1457        let waiting = node("accept").unwrap();
1458        let config =
1459            C2Config::new("127.0.0.1:9000".parse().unwrap(), "safe-token", vec![waiting]).unwrap();
1460        for refused in ["0.0.0.0:48200", "127.0.0.1:0", "[::]:48200"] {
1461            assert!(
1462                matches!(
1463                    config.clone().with_node_listen(refused.parse().unwrap()),
1464                    Err(C2ConfigError::NonLoopbackNodeListen(_)),
1465                ),
1466                "accepted {refused}",
1467            );
1468        }
1469        let tunnel: SocketAddr = "10.88.1.2:27442".parse().unwrap();
1470        let accepted = config.clone().with_node_listen(tunnel).unwrap();
1471        assert_eq!(accepted.node_listen, Some(tunnel));
1472        // Sharing the API's own address would mean HTTP and node frames
1473        // arriving on one socket; neither parser would survive the other.
1474        assert!(matches!(
1475            config.with_node_listen("127.0.0.1:9000".parse().unwrap()),
1476            Err(C2ConfigError::NodeListenConflict),
1477        ));
1478    }
1479
1480    #[test]
1481    fn ssh_forwarded_loopback_route_is_strict_canonical_and_control_stays_local() {
1482        let ipv4 = node("tcp://127.0.0.1:48100").unwrap();
1483        assert_eq!(ipv4.endpoint, "tcp://127.0.0.1:48100");
1484        assert_eq!(ipv4.route, C2NodeRoute::SshForwardedLoopback("127.0.0.1:48100".parse().unwrap()));
1485        assert_eq!(ipv4.transport_label(), "ssh-forwarded-loopback");
1486
1487        let ipv6 = node("tcp://[0:0:0:0:0:0:0:1]:48100").unwrap();
1488        assert_eq!(ipv6.endpoint, "tcp://[::1]:48100");
1489        assert_eq!(ipv6.transport_label(), "ssh-forwarded-loopback");
1490
1491        for invalid in [
1492            "tcp://localhost:48100",
1493            "tcp://127.0.0.2:48100",
1494            "tcp://0.0.0.0:48100",
1495            "tcp://127.0.0.1:0",
1496            "tcp://user@127.0.0.1:48100",
1497            "tcp://127.0.0.1:48100/path",
1498            "TCP://127.0.0.1:48100",
1499        ] {
1500            assert!(matches!(node(invalid), Err(C2ConfigError::InvalidEndpoint(_))), "accepted {invalid}");
1501        }
1502
1503        assert!(matches!(
1504            C2Config::new(
1505                "127.0.0.1:0".parse().unwrap(),
1506                "safe-token",
1507                vec![ipv4.clone(), C2NodeConfig::new(
1508                    NodeId::new("duplicate-route").unwrap(),
1509                    "tcp://127.0.0.1:48100",
1510                    "safe-token",
1511                ).unwrap()],
1512            ),
1513            Err(C2ConfigError::DuplicateEndpoint)
1514        ));
1515        assert!(matches!(
1516            C2Config::new("127.0.0.1:0".parse().unwrap(), "safe-token", vec![ipv4])
1517                .unwrap()
1518                .with_control_endpoint("tcp://127.0.0.1:48101"),
1519            Err(C2ConfigError::InvalidControlEndpoint)
1520        ));
1521    }
1522}
1523
1524#[cfg(all(test, windows))]
1525mod tests {
1526    // A2: former g4a relay unit tests removed with node_relay_worker.
1527    // Inventory/HTTP coverage lives in unix e2e and restreamer tests.
1528}
1529
1530#[cfg(test)]
1531mod control_tunnel_listen_tests {
1532    use super::*;
1533
1534    fn config() -> C2Config {
1535        let node = C2NodeConfig::new(
1536            NodeId::new("node-a").unwrap(),
1537            if cfg!(unix) { "/tmp/gate4agent-node-a.sock" } else { r"\\.\pipe\gate4agent-node-a" },
1538            "token-token",
1539        ).unwrap();
1540        C2Config::new("127.0.0.1:18321".parse().unwrap(), "api-token", vec![node]).unwrap()
1541    }
1542
1543    #[test]
1544    fn control_tunnel_rejects_unspecified_and_accepts_tunnel_address() {
1545        let bad: SocketAddr = "0.0.0.0:27441".parse().unwrap();
1546        assert!(matches!(
1547            config().with_control_tunnel_listen(bad),
1548            Err(C2ConfigError::InvalidControlTunnel(_))
1549        ));
1550        let ok: SocketAddr = "10.88.1.2:27441".parse().unwrap();
1551        let config = config().with_control_tunnel_listen(ok).unwrap();
1552        assert_eq!(config.control_tunnel_listen, Some(ok));
1553        assert!(!config.control_endpoint.is_empty());
1554    }
1555}
1556
1557
1558#[cfg(test)]
1559mod opaque_restreamer_tests {
1560    use super::*;
1561    use std::time::Duration;
1562
1563    #[tokio::test]
1564    async fn with_restreamer_config_starts_without_g4a_control_listener() {
1565        // Waiting node: no dial-out. With restreamer, no g4a relay worker either.
1566        let node = C2NodeConfig::new(
1567            NodeId::new("n1").unwrap(),
1568            "accept",
1569            "safe-token",
1570        ).unwrap();
1571        let node_up = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
1572        let drain = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
1573        let sock = std::env::temp_dir().join(format!("c2-opaque-{}.sock", std::process::id()));
1574        let _ = std::fs::remove_file(&sock);
1575        let config = C2Config::new("127.0.0.1:0".parse().unwrap(), "opaque-token-01234567", vec![node])
1576            .unwrap()
1577            .with_control_endpoint(sock.to_string_lossy())
1578            .unwrap()
1579            .with_restreamer(node_up, drain, "drain-token-0123456789ab")
1580            .unwrap()
1581            .with_timings(C2Timings {
1582                attempt_deadline: Duration::from_millis(50),
1583                ..C2Timings::default()
1584            });
1585        let running = C2Running::start(config).await.unwrap();
1586        tokio::time::sleep(Duration::from_millis(80)).await;
1587        assert!(!sock.exists(), "C2 must not bind the legacy operator control socket");
1588        let client = crate::restreamer::DrainClient::new(drain, "drain-token-0123456789ab");
1589        let mut session = client.connect().await.expect("drain connect");
1590        session.open("n1", 1, hatchery_core::ChannelKind::G4aControl).await.unwrap();
1591        session.resume(0).await.unwrap();
1592        running.shutdown_handle().shutdown();
1593        let _ = running.wait().await;
1594        let _ = std::fs::remove_file(&sock);
1595    }
1596}