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 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 pub control_endpoint: String,
144 pub control_tunnel_listen: Option<SocketAddr>,
146 api_token: String,
147 pub nodes: Vec<C2NodeConfig>,
148 pub node_listen: Option<SocketAddr>,
151 pub timings: C2Timings,
152 pub enrollment: Option<crate::enroll_service::EnrollConfig>,
155 pub restreamer_node_listen: Option<SocketAddr>,
157 pub restreamer_drain_listen: Option<SocketAddr>,
159 restreamer_core_token: Option<String>,
161}
162
163impl C2Config {
164 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 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 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 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 pub fn with_node_listen(mut self, node_listen: SocketAddr) -> Result<Self, C2ConfigError> {
257 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 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 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 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 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#[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
581type 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 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 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 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 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 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
826async 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 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 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
920async 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 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 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 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 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 #[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 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 #[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 #[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 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 }
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 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}