1use std::collections::HashMap;
7use std::io;
8use std::net::{IpAddr, SocketAddr};
9use std::process::Command;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use hatchery_enroll::{CallHomeChallenge, EnrollmentRequest, EnrollmentResponse, Policy, ANSWER_LEN};
14use serde::{Deserialize, Serialize};
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::{TcpListener, TcpStream};
17use tokio::sync::{mpsc, watch};
18use tokio::time::timeout;
19
20use crate::enrollment::{EnrollmentError, EnrollmentRegistry, NodeRecord, NodeStatus, TunnelPool};
21use hatchery_enroll::call::ForwardedCall;
22
23const MAX_BODY: usize = 16 * 1024;
24const IO_DEADLINE: Duration = Duration::from_secs(5);
25const BUILD_DEADLINE: Duration = Duration::from_secs(20 * 60);
26const DEFAULT_CODE_TTL_MS: u64 = 15 * 60 * 1000;
27
28pub trait CapabilityGate: Send + Sync {
31 fn allows(&self, node_id: &str, capability: &str) -> Option<bool>;
32}
33
34#[async_trait::async_trait]
39pub trait NodeCallSink: Send + Sync {
40 async fn apply(&self, call: ForwardedCall) -> HttpReply;
41}
42
43pub trait PeerProvisioner: Send + Sync {
45 fn add_peer(&self, wg_public_key: &str, tunnel_ip: &str) -> io::Result<()>;
46 fn remove_peer(&self, wg_public_key: &str) -> io::Result<()>;
47}
48
49pub struct WgCli {
52 pub interface: String,
53}
54
55impl PeerProvisioner for WgCli {
56 fn add_peer(&self, key: &str, ip: &str) -> io::Result<()> {
57 run_wg(&["set", &self.interface, "peer", key, "allowed-ips", &format!("{ip}/32")])
58 }
59 fn remove_peer(&self, key: &str) -> io::Result<()> {
60 run_wg(&["set", &self.interface, "peer", key, "remove"])
61 }
62}
63
64fn run_wg(args: &[&str]) -> io::Result<()> {
65 let output = Command::new("wg").args(args).output()?;
66 if output.status.success() {
67 Ok(())
68 } else {
69 Err(io::Error::other(format!("wg exited with {}", output.status)))
70 }
71}
72
73pub struct NoopProvisioner;
75
76impl PeerProvisioner for NoopProvisioner {
77 fn add_peer(&self, _: &str, _: &str) -> io::Result<()> {
78 Ok(())
79 }
80 fn remove_peer(&self, _: &str) -> io::Result<()> {
81 Ok(())
82 }
83}
84
85#[derive(Clone, Default)]
87pub struct PublicInfo {
88 pub c2_tunnel_ip: Option<String>,
89 pub c2_wg_public_key: Option<String>,
90 pub c2_wg_endpoint: Option<String>,
91 pub c2_call_home: Option<String>,
92 pub upstream_c2_public_key: Option<String>,
94 pub upstream_recipient_public_key: Option<String>,
96}
97
98#[derive(Clone)]
99pub struct EnrollConfig {
100 pub registry_path: Option<std::path::PathBuf>,
101 pub require_code: bool,
103 pub tls_only: bool,
106 pub pool: Option<TunnelPool>,
107 pub listen: SocketAddr,
109 pub provisioner: Arc<dyn PeerProvisioner>,
110 pub public: PublicInfo,
111 pub rate_limit_per_minute: u32,
113 pub policy_path: Option<std::path::PathBuf>,
115 pub build_host: Option<(SocketAddr, String)>,
117 pub server_upstream: Option<(SocketAddr, String)>,
120 pub node_call_sink: Option<Arc<dyn NodeCallSink>>,
122}
123
124impl EnrollConfig {
125 pub fn new(listen: SocketAddr) -> Self {
126 Self {
127 registry_path: None,
128 require_code: true,
129 tls_only: false,
130 pool: None,
131 listen,
132 provisioner: Arc::new(NoopProvisioner),
133 public: PublicInfo::default(),
134 rate_limit_per_minute: 30,
135 policy_path: None,
136 build_host: None,
137 server_upstream: None,
138 node_call_sink: None,
139 }
140 }
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
145pub enum EnrollEvent {
146 Enrolled(String),
148 StatusChanged(String, NodeStatus),
150 PolicyChanged,
153}
154
155pub struct EnrollService {
156 registry: Mutex<EnrollmentRegistry>,
157 policy: std::sync::RwLock<Policy>,
158 policy_path: Option<std::path::PathBuf>,
159 provisioner: Arc<dyn PeerProvisioner>,
160 public: PublicInfo,
161 events: mpsc::UnboundedSender<EnrollEvent>,
162 rate: Mutex<HashMap<IpAddr, (Instant, u32)>>,
163 rate_limit: u32,
164 tls_only: bool,
165 build_host: Option<(SocketAddr, String)>,
166 server_upstream: Option<(SocketAddr, String)>,
167 node_call_sink: Option<Arc<dyn NodeCallSink>>,
168 build_nonces: Mutex<HashMap<(String, String), u64>>,
169 node_rate: Mutex<HashMap<(String, &'static str), (u64, u32)>>,
170 pub listen: SocketAddr,
171}
172
173#[derive(Serialize)]
174struct ErrorBody<'a> {
175 error: &'a str,
176}
177
178pub struct HttpReply {
179 pub status: u16,
180 pub body: Vec<u8>,
181}
182
183impl HttpReply {
184 fn json<T: Serialize>(status: u16, value: &T) -> Self {
185 Self { status, body: serde_json::to_vec(value).unwrap_or_default() }
186 }
187 fn error(status: u16, message: &str) -> Self {
188 Self::json(status, &ErrorBody { error: message })
189 }
190}
191
192fn unix_ms() -> u64 {
193 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
194}
195
196fn is_active(status: NodeStatus) -> bool {
197 !matches!(status, NodeStatus::Suspended | NodeStatus::Revoked)
198}
199
200impl EnrollService {
201 pub fn start(config: &EnrollConfig) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<EnrollEvent>), EnrollmentError> {
202 let mut registry = match &config.registry_path {
203 Some(path) => EnrollmentRegistry::open(path, config.require_code)?,
204 None => EnrollmentRegistry::in_memory(config.require_code),
205 };
206 if let Some(pool) = config.pool {
207 registry = registry.with_pool(pool);
208 }
209 let policy = match &config.policy_path {
210 Some(path) => Policy::load(path).map_err(|e| EnrollmentError::Storage(e.to_string()))?,
211 None => Policy::default(),
212 };
213 let (tx, rx) = mpsc::unbounded_channel();
214 let service = Arc::new(Self {
215 registry: Mutex::new(registry),
216 policy: std::sync::RwLock::new(policy),
217 policy_path: config.policy_path.clone(),
218 provisioner: Arc::clone(&config.provisioner),
219 public: config.public.clone(),
220 events: tx,
221 rate: Mutex::new(HashMap::new()),
222 rate_limit: config.rate_limit_per_minute.max(1),
223 tls_only: config.tls_only,
224 build_host: config.build_host.clone(),
225 server_upstream: config.server_upstream.clone(),
226 node_call_sink: config.node_call_sink.clone(),
227 build_nonces: Mutex::new(HashMap::new()),
228 node_rate: Mutex::new(HashMap::new()),
229 listen: config.listen,
230 });
231 for record in service.registry.lock().expect("registry lock").list() {
233 if is_active(record.status) {
234 service.apply_peer(&record);
235 let _ = service.events.send(EnrollEvent::Enrolled(record.node_id.clone()));
236 }
237 }
238 Ok((service, rx))
239 }
240
241 fn apply_peer(&self, record: &NodeRecord) {
242 let Some(ip) = &record.tunnel_ip else { return };
243 let result = if is_active(record.status) {
244 self.provisioner.add_peer(&record.wg_public_key, ip)
245 } else {
246 self.provisioner.remove_peer(&record.wg_public_key)
247 };
248 if let Err(error) = result {
249 tracing::warn!(node_id = %record.node_id, cause = %error, "wireguard peer update failed");
250 }
251 }
252
253 fn allow(&self, peer: IpAddr) -> bool {
254 let mut rate = self.rate.lock().expect("rate lock");
255 let now = Instant::now();
256 let entry = rate.entry(peer).or_insert((now, 0));
257 if now.duration_since(entry.0) > Duration::from_secs(60) {
258 *entry = (now, 0);
259 }
260 entry.1 += 1;
261 entry.1 <= self.rate_limit
262 }
263
264 pub fn handle_enroll(&self, body: &[u8], peer: IpAddr) -> HttpReply {
266 if !self.allow(peer) {
267 return HttpReply::error(429, "rate limited");
268 }
269 let Ok(request) = serde_json::from_slice::<EnrollmentRequest>(body) else {
270 return HttpReply::error(400, "malformed request");
271 };
272 if self.tls_only && !peer.is_loopback() {
273 if let Some(code) = request.code.as_deref() {
274 let burned = self.registry.lock().expect("registry lock").burn_code(code, unix_ms());
275 tracing::warn!(%peer, burned, "plaintext enrollment refused; the exposed code was burned (use https)");
276 }
277 return HttpReply::error(403, "enrollment requires https");
278 }
279 let result = self.registry.lock().expect("registry lock").submit(&request, unix_ms());
280 match result {
281 Ok(record) => {
282 self.apply_peer(&record);
283 let _ = self.events.send(EnrollEvent::Enrolled(record.node_id.clone()));
284 tracing::info!(node_id = %record.node_id, status = ?record.status, "node enrolled");
285 HttpReply::json(
286 200,
287 &EnrollmentResponse {
288 node_id: record.node_id,
289 status: format!("{:?}", record.status).to_lowercase(),
290 tunnel_ip: record.tunnel_ip,
291 c2_tunnel_ip: self.public.c2_tunnel_ip.clone(),
292 c2_wg_public_key: self.public.c2_wg_public_key.clone(),
293 c2_wg_endpoint: self.public.c2_wg_endpoint.clone(),
294 c2_call_home: self.public.c2_call_home.clone(),
295 upstream_c2_public_key: self.public.upstream_c2_public_key.clone(),
296 upstream_recipient_public_key: self.public.upstream_recipient_public_key.clone(),
297 },
298 )
299 }
300 Err(error) => {
301 tracing::warn!(cause = %error, %peer, "enrollment refused");
302 match error {
304 EnrollmentError::Storage(_) => HttpReply::error(503, "enrollment unavailable"),
305 EnrollmentError::Revoked => HttpReply::error(403, "refused"),
306 _ => HttpReply::error(403, "refused"),
307 }
308 }
309 }
310 }
311
312 fn node_allow(&self, node_id: &str, kind: &'static str, per_minute: u32) -> bool {
315 let now = unix_ms();
316 let mut map = self.node_rate.lock().expect("rate lock");
317 let slot = map.entry((node_id.to_owned(), kind)).or_insert((now, 0));
318 if now.saturating_sub(slot.0) >= 60_000 {
319 *slot = (now, 0);
320 }
321 slot.1 += 1;
322 slot.1 <= per_minute
323 }
324
325 pub async fn handle_build(&self, body: &[u8], peer: IpAddr) -> HttpReply {
330 use hatchery_build::wire::{HostRequest, SignedBuildRequest, MAX_SKEW_MS};
331 let Ok(request) = serde_json::from_slice::<SignedBuildRequest>(body) else {
332 return HttpReply::error(400, "malformed request");
333 };
334 if !request.well_formed() {
335 return HttpReply::error(400, "malformed request");
336 }
337 let now = unix_ms();
338 if now.abs_diff(request.ts_ms) > MAX_SKEW_MS {
339 return HttpReply::error(403, "refused");
340 }
341 let message = SignedBuildRequest::signing_bytes(&request.node_id, &request.recipe, request.source_ref.as_deref(), request.ts_ms, &request.nonce);
342 let verified = self.registry.lock().expect("registry lock").verify_node_message(&request.node_id, &message, &request.signature);
343 if verified.is_err() {
344 tracing::warn!(node_id = %request.node_id, %peer, "build request refused: signature");
345 return HttpReply::error(403, "refused");
346 }
347 if !self.node_allow(&request.node_id, "build", 30) {
348 return HttpReply::error(429, "rate limited");
349 }
350 {
351 let mut seen = self.build_nonces.lock().expect("nonce lock");
352 seen.retain(|_, ts| now.abs_diff(*ts) <= MAX_SKEW_MS * 2);
353 if seen.insert((request.node_id.clone(), request.nonce.clone()), request.ts_ms).is_some() {
354 tracing::warn!(node_id = %request.node_id, "build request refused: replay");
355 return HttpReply::error(403, "refused");
356 }
357 }
358 if self.allows(&request.node_id, "build") != Some(true) {
359 tracing::warn!(node_id = %request.node_id, "build request refused: status does not hold build");
360 return HttpReply::error(403, "refused");
361 }
362 let Some((addr, bearer)) = &self.build_host else {
363 return HttpReply::error(503, "build service unavailable");
364 };
365 let forward = HostRequest { node_id: request.node_id.clone(), recipe: request.recipe.clone(), source_ref: request.source_ref.clone() };
366 let payload = serde_json::to_vec(&forward).unwrap_or_default();
367 match hatchery_build::http::post(*addr, "/v1/build", Some(bearer), &payload, BUILD_DEADLINE).await {
368 Ok((status, body)) => {
369 tracing::info!(node_id = %request.node_id, recipe = %request.recipe, status, "build forwarded");
370 HttpReply { status, body }
371 }
372 Err(error) => {
373 tracing::warn!(node_id = %request.node_id, cause = %error, "build host unreachable");
374 HttpReply::error(502, "build service unavailable")
375 }
376 }
377 }
378
379 pub async fn handle_node_call(&self, body: &[u8], peer: IpAddr) -> HttpReply {
383 use hatchery_enroll::call::{capability_for, SignedCall, MAX_SKEW_MS};
384 let Ok(call) = serde_json::from_slice::<SignedCall>(body) else {
385 return HttpReply::error(400, "malformed request");
386 };
387 if !call.well_formed() {
388 return HttpReply::error(400, "malformed request");
389 }
390 let now = unix_ms();
391 if now.abs_diff(call.ts_ms) > MAX_SKEW_MS {
392 return HttpReply::error(403, "refused");
393 }
394 let message = SignedCall::signing_bytes(&call.node_id, &call.op, &call.payload, call.ts_ms, &call.nonce);
395 if self.registry.lock().expect("registry lock").verify_node_message(&call.node_id, &message, &call.signature).is_err() {
396 tracing::warn!(node_id = %call.node_id, %peer, "node call refused: signature");
397 return HttpReply::error(403, "refused");
398 }
399 if !self.node_allow(&call.node_id, "call", 600) {
400 return HttpReply::error(429, "rate limited");
401 }
402 {
403 let mut seen = self.build_nonces.lock().expect("nonce lock");
404 seen.retain(|_, ts| now.abs_diff(*ts) <= MAX_SKEW_MS * 2);
405 if seen.insert((call.node_id.clone(), call.nonce.clone()), call.ts_ms).is_some() {
406 tracing::warn!(node_id = %call.node_id, "node call refused: replay");
407 return HttpReply::error(403, "refused");
408 }
409 }
410 let capability = capability_for(&call.op).unwrap_or("none");
411 if self.allows(&call.node_id, capability) != Some(true) {
412 tracing::warn!(node_id = %call.node_id, op = %call.op, "node call refused: status does not hold the capability");
413 return HttpReply::error(403, "refused");
414 }
415 let _ = capability;
417 let Ok(payload) = serde_json::from_str::<serde_json::Value>(&call.payload) else {
418 return HttpReply::error(400, "malformed payload");
419 };
420 let forward = ForwardedCall {
421 node_id: call.node_id.clone(),
422 op: call.op.clone(),
423 payload,
424 };
425 let Some(sink) = &self.node_call_sink else {
426 if self.server_upstream.is_some() {
427 tracing::warn!(
428 node_id = %call.node_id,
429 op = %call.op,
430 "node-call push to core is removed (A2); configure node_call_sink"
431 );
432 }
433 return HttpReply::error(410, "use restreamer uplink");
434 };
435 sink.apply(forward).await
436 }
437
438 pub fn handle_operator(&self, method: &str, path: &str, body: &[u8]) -> HttpReply {
440 let rest = path.trim_start_matches("/v1/enroll").trim_matches('/');
441 let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
442 match (method, parts.as_slice()) {
443 ("POST", ["codes"]) => {
444 #[derive(Deserialize, Default)]
445 struct Body {
446 ttl_ms: Option<u64>,
447 }
448 let ttl = serde_json::from_slice::<Body>(body).unwrap_or_default().ttl_ms.unwrap_or(DEFAULT_CODE_TTL_MS);
449 match self.registry.lock().expect("registry lock").issue_code(unix_ms(), ttl.min(24 * 3600 * 1000), "operator") {
450 Ok(code) => HttpReply::json(200, &serde_json::json!({ "code": code, "ttl_ms": ttl })),
451 Err(_) => HttpReply::error(503, "storage failed"),
452 }
453 }
454 ("GET", ["nodes"]) => {
455 let nodes = self.registry.lock().expect("registry lock").list();
456 let policy = self.policy.read().expect("policy lock");
457 let nodes: Vec<serde_json::Value> = nodes
458 .into_iter()
459 .map(|n| {
460 let grants = policy.grants(n.status);
461 let mut value = serde_json::to_value(&n).unwrap_or_default();
462 value["grants"] = serde_json::to_value(grants).unwrap_or_default();
463 value
464 })
465 .collect();
466 HttpReply::json(200, &serde_json::json!({ "nodes": nodes }))
467 }
468 ("GET", ["policy"]) => HttpReply::json(200, &*self.policy.read().expect("policy lock")),
469 ("POST", ["policy", "reload"]) => match self.reload_policy() {
470 Ok(()) => HttpReply::json(200, &*self.policy.read().expect("policy lock")),
471 Err(message) => HttpReply::json(409, &serde_json::json!({ "error": message })),
472 },
473 ("POST", ["nodes", id, "allows"]) => {
474 #[derive(Deserialize)]
475 struct Body {
476 capability: String,
477 }
478 match serde_json::from_slice::<Body>(body) {
479 Ok(b) => match self.allows(id, &b.capability) {
480 Some(allowed) => HttpReply::json(200, &serde_json::json!({ "allowed": allowed })),
481 None => HttpReply::error(404, "unknown node"),
482 },
483 Err(_) => HttpReply::error(400, "body must be {\"capability\": ...}"),
484 }
485 }
486 ("POST", ["nodes", id, "status"]) => {
487 #[derive(Deserialize)]
488 struct Body {
489 status: NodeStatus,
490 }
491 match serde_json::from_slice::<Body>(body) {
492 Ok(b) => self.set_status(id, b.status),
493 Err(_) => HttpReply::error(400, "body must be {\"status\": neutral|member|trusted|suspended|revoked}"),
494 }
495 }
496 ("POST", ["nodes", id, "revoke"]) => self.set_status(id, NodeStatus::Revoked),
497 _ => HttpReply::error(404, "not found"),
498 }
499 }
500
501 fn set_status(&self, id: &str, status: NodeStatus) -> HttpReply {
502 let result = self.registry.lock().expect("registry lock").set_status(id, status, "operator", unix_ms());
503 match result {
504 Ok(record) => {
505 self.apply_peer(&record);
506 let _ = self.events.send(EnrollEvent::StatusChanged(record.node_id.clone(), record.status));
507 tracing::info!(node_id = %record.node_id, status = ?record.status, "node status changed");
508 HttpReply::json(200, &record)
509 }
510 Err(EnrollmentError::Unknown) => HttpReply::error(404, "unknown node"),
511 Err(EnrollmentError::Revoked) => HttpReply::error(409, "node is revoked"),
512 Err(_) => HttpReply::error(503, "storage failed"),
513 }
514 }
515
516 pub fn callhome_allowed(&self, node_id: &str) -> bool {
518 self.allows(node_id, "heartbeat").unwrap_or(false)
519 }
520
521 pub fn allows(&self, node_id: &str, capability: &str) -> Option<bool> {
523 let status = self.registry.lock().expect("registry lock").get(node_id).map(|r| r.status)?;
524 Some(self.policy.read().expect("policy lock").allows(status, capability))
525 }
526
527 pub fn reload_policy(&self) -> Result<(), String> {
529 let path = self.policy_path.as_ref().ok_or_else(|| "no policy file is configured".to_owned())?;
530 let next = Policy::load(path).map_err(|e| e.to_string())?;
531 *self.policy.write().expect("policy lock") = next;
532 let _ = self.events.send(EnrollEvent::PolicyChanged);
533 tracing::info!("policy reloaded");
534 Ok(())
535 }
536
537 pub fn nodes_without(&self, capability: &str) -> Vec<String> {
539 let policy = self.policy.read().expect("policy lock");
540 self.registry
541 .lock()
542 .expect("registry lock")
543 .list()
544 .into_iter()
545 .filter(|r| !policy.allows(r.status, capability))
546 .map(|r| r.node_id)
547 .collect()
548 }
549
550 pub async fn signed_call_home(&self, stream: &mut TcpStream, node_id: &str) -> Result<String, String> {
553 if !self.callhome_allowed(node_id) {
554 return Err("node is not enrolled or not allowed".to_owned());
555 }
556 let challenge = CallHomeChallenge::new().map_err(|e| e.to_string())?;
557 timeout(IO_DEADLINE, stream.write_all(&challenge.frame())).await.map_err(|_| "challenge write timed out")?.map_err(|e| e.to_string())?;
558 let mut answer = [0_u8; ANSWER_LEN];
559 timeout(IO_DEADLINE, stream.read_exact(&mut answer)).await.map_err(|_| "answer timed out")?.map_err(|e| e.to_string())?;
560 let verified = challenge.accept_answer(&answer);
561 let status = self
562 .registry
563 .lock()
564 .expect("registry lock")
565 .verify_node_signature(node_id, &verified.material, &verified.signature_b64)
566 .map_err(|e| e.to_string())?;
567 if !is_active(status) || !self.policy.read().expect("policy lock").allows(status, "heartbeat") {
568 return Err("node status does not permit calling home".to_owned());
569 }
570 Ok(verified.session_token)
571 }
572}
573
574pub async fn serve_enroll(listener: TcpListener, service: Arc<EnrollService>, mut shutdown: watch::Receiver<bool>) -> io::Result<()> {
576 tracing::info!(listen = %service.listen, "enrollment listener bound");
577 let permits = Arc::new(tokio::sync::Semaphore::new(64));
578 loop {
579 let (stream, peer) = tokio::select! {
580 accepted = listener.accept() => accepted?,
581 changed = shutdown.changed() => {
582 if changed.is_err() || *shutdown.borrow() { return Ok(()); }
583 continue;
584 }
585 };
586 let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { continue };
587 let service = Arc::clone(&service);
588 tokio::spawn(async move {
589 let _permit = permit;
590 let _ = serve_one(stream, peer.ip(), &service).await;
591 });
592 }
593}
594
595async fn serve_one(mut stream: TcpStream, peer: IpAddr, service: &EnrollService) -> io::Result<()> {
596 let request = timeout(IO_DEADLINE * 2, read_http(&mut stream)).await;
597 let reply = match request {
598 Ok(Ok((method, path, body))) if method == "POST" && path == "/v1/enroll" => service.handle_enroll(&body, peer),
599 Ok(Ok((method, path, body))) if method == "POST" && path == "/v1/build" => {
600 timeout(BUILD_DEADLINE + IO_DEADLINE, service.handle_build(&body, peer))
601 .await
602 .unwrap_or_else(|_| HttpReply::error(504, "build timed out"))
603 }
604 Ok(Ok((method, path, body))) if method == "POST" && path == "/v1/node-call" => {
605 timeout(Duration::from_secs(45), service.handle_node_call(&body, peer))
606 .await
607 .unwrap_or_else(|_| HttpReply::error(504, "timed out"))
608 }
609 Ok(Ok(_)) => HttpReply::error(404, "not found"),
610 _ => HttpReply::error(400, "bad request"),
611 };
612 let reason = match reply.status {
613 200 => "OK",
614 400 => "Bad Request",
615 403 => "Forbidden",
616 404 => "Not Found",
617 429 => "Too Many Requests",
618 422 => "Unprocessable Entity",
619 502 => "Bad Gateway",
620 504 => "Gateway Timeout",
621 _ => "Service Unavailable",
622 };
623 let head = format!(
624 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
625 reply.status,
626 reason,
627 reply.body.len()
628 );
629 stream.write_all(head.as_bytes()).await?;
630 stream.write_all(&reply.body).await?;
631 stream.shutdown().await
632}
633
634pub async fn read_http(stream: &mut TcpStream) -> io::Result<(String, String, Vec<u8>)> {
636 let mut bytes = Vec::with_capacity(2048);
637 let mut chunk = [0_u8; 2048];
638 let header_end = loop {
639 let n = stream.read(&mut chunk).await?;
640 if n == 0 {
641 return Err(io::ErrorKind::UnexpectedEof.into());
642 }
643 bytes.extend_from_slice(&chunk[..n]);
644 if bytes.len() > MAX_BODY + 8192 {
645 return Err(io::Error::new(io::ErrorKind::InvalidData, "too large"));
646 }
647 if let Some(pos) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
648 break pos + 4;
649 }
650 };
651 let head = std::str::from_utf8(&bytes[..header_end]).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "utf8"))?;
652 let mut lines = head.split("\r\n");
653 let mut first = lines.next().unwrap_or_default().split_whitespace();
654 let method = first.next().unwrap_or_default().to_owned();
655 let path = first.next().unwrap_or_default().to_owned();
656 let mut length = 0_usize;
657 for line in lines {
658 if let Some((name, value)) = line.split_once(':') {
659 if name.eq_ignore_ascii_case("content-length") {
660 length = value.trim().parse().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "length"))?;
661 }
662 }
663 }
664 if length > MAX_BODY {
665 return Err(io::Error::new(io::ErrorKind::InvalidData, "body too large"));
666 }
667 let mut body = bytes[header_end..].to_vec();
668 while body.len() < length {
669 let n = stream.read(&mut chunk).await?;
670 if n == 0 {
671 return Err(io::ErrorKind::UnexpectedEof.into());
672 }
673 body.extend_from_slice(&chunk[..n]);
674 }
675 body.truncate(length);
676 Ok((method, path, body))
677}
678
679impl CapabilityGate for EnrollService {
680 fn allows(&self, node_id: &str, capability: &str) -> Option<bool> {
681 EnrollService::allows(self, node_id, capability)
682 }
683}
684
685#[cfg(test)]
686mod tls_only_tests {
687 use super::*;
688 use hatchery_enroll::{NodeIdentity, NodeKind};
689
690 fn code(service: &EnrollService) -> String {
691 let reply = service.handle_operator("POST", "/v1/enroll/codes", b"{}");
692 assert_eq!(reply.status, 200);
693 serde_json::from_slice::<serde_json::Value>(&reply.body).unwrap()["code"].as_str().unwrap().to_owned()
694 }
695
696 fn request(dir: &std::path::Path, id: &str, code: &str) -> Vec<u8> {
697 let identity = NodeIdentity::load_or_create(dir).unwrap();
698 serde_json::to_vec(&identity.sign_request(id, NodeKind::Local, Some(code), unix_ms()).unwrap()).unwrap()
699 }
700
701 #[test]
702 fn a_code_sent_in_the_clear_is_refused_and_burned_while_the_proxy_path_works() {
703 let dir = std::env::temp_dir().join(format!("c2-tls-only-{}", std::process::id()));
704 let mut config = EnrollConfig::new("127.0.0.1:0".parse().unwrap());
705 config.tls_only = true;
706 let (service, _events) = EnrollService::start(&config).unwrap();
707 let public: IpAddr = "203.0.113.9".parse().unwrap();
708 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
709
710 let leaked = code(&service);
711 let body = request(&dir.join("a"), "alpha", &leaked);
712 assert_eq!(service.handle_enroll(&body, public).status, 403);
713 let body = request(&dir.join("a"), "alpha", &leaked);
715 assert_eq!(service.handle_enroll(&body, loopback).status, 403);
716
717 let fresh = code(&service);
718 let body = request(&dir.join("b"), "beta", &fresh);
719 assert_eq!(service.handle_enroll(&body, loopback).status, 200);
720 let _ = std::fs::remove_dir_all(&dir);
721 }
722
723 #[test]
724 fn without_the_flag_plaintext_still_enrolls() {
725 let dir = std::env::temp_dir().join(format!("c2-tls-off-{}", std::process::id()));
726 let (service, _events) = EnrollService::start(&EnrollConfig::new("127.0.0.1:0".parse().unwrap())).unwrap();
727 let c = code(&service);
728 let body = request(&dir, "gamma", &c);
729 assert_eq!(service.handle_enroll(&body, "203.0.113.9".parse().unwrap()).status, 200);
730 let _ = std::fs::remove_dir_all(&dir);
731 }
732
733 #[tokio::test]
734 async fn node_call_no_longer_pushes_to_core() {
735 use hatchery_enroll::call::SignedCall;
736 let dir = std::env::temp_dir().join(format!("c2-no-push-{}", std::process::id()));
737 let mut config = EnrollConfig::new("127.0.0.1:0".parse().unwrap());
738 config.server_upstream = Some(("127.0.0.1:9".parse().unwrap(), "token-not-used-here!".into()));
740 let (service, _events) = EnrollService::start(&config).unwrap();
741 let identity = NodeIdentity::load_or_create(&dir).unwrap();
742 let c = code(&service);
743 let body = request(&dir, "delta", &c);
744 assert_eq!(service.handle_enroll(&body, "127.0.0.1".parse().unwrap()).status, 200);
745 service.handle_operator(
746 "POST",
747 "/v1/enroll/nodes/delta/status",
748 br#"{"status":"trusted"}"#,
749 );
750 let ts = unix_ms();
751 let payload = "{}".to_owned();
752 let msg = SignedCall::signing_bytes("delta", "mail.inbox", &payload, ts, "noncea21");
753 let signed = SignedCall {
754 node_id: "delta".into(),
755 op: "mail.inbox".into(),
756 payload,
757 ts_ms: ts,
758 nonce: "noncea21".into(),
759 signature: identity.sign_bytes(&msg),
760 };
761 let reply = service
762 .handle_node_call(&serde_json::to_vec(&signed).unwrap(), "127.0.0.1".parse().unwrap())
763 .await;
764 assert_eq!(reply.status, 410, "push path removed");
765 let _ = std::fs::remove_dir_all(&dir);
766 }
767}