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