1use crate::auth_layer::AuthLayer;
25use crate::authzen::{build_state, build_state_with_options, router, AppStateOptions};
26use crate::listener::{Listener, ServerConfig};
27use agentguard_core::decision::{cache::DecisionCache, RotationConfig};
28use agentguard_core::decode_chain_secret;
29use agentguard_policy::watcher::{watch as policy_watch, PolicyWatcher};
30use anyhow::{anyhow, Result};
31use axum::serve::serve;
32use std::path::Path;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::net::TcpListener;
36use tokio::signal;
37
38pub(crate) fn validate_tls_paths(cert: &Path, key: &Path) -> Result<()> {
43 for (label, path) in [("cert", cert), ("key", key)] {
44 let meta = std::fs::metadata(path)
45 .map_err(|e| anyhow!("tls {label} {:?} not readable: {e}", path.display()))?;
46 if !meta.is_file() {
47 return Err(anyhow!(
48 "tls {label} {:?} is not a regular file",
49 path.display()
50 ));
51 }
52 if meta.len() == 0 {
53 return Err(anyhow!("tls {label} {:?} is empty", path.display()));
54 }
55 }
56 Ok(())
57}
58
59fn validate_grpc_listener(addr: std::net::SocketAddr) -> Result<()> {
60 if !addr.ip().is_loopback() {
61 return Err(anyhow!(
62 "gRPC listener must be loopback-bound until TLS is supported"
63 ));
64 }
65 Ok(())
66}
67
68fn parse_audit_rotation(value: Option<&str>) -> Result<Option<RotationConfig>> {
69 value
70 .map(RotationConfig::parse)
71 .transpose()
72 .map_err(|error| anyhow!("AGENTGUARD_AUDIT_MAX_BYTES {error}"))
73}
74
75fn validate_listener_security(
76 listener: &Listener,
77 grpc_listener: Option<std::net::SocketAddr>,
78 auth: &crate::listener::AuthConfig,
79 allow_loopback_bypass: bool,
80) -> Result<()> {
81 if let Some(addr) = grpc_listener {
82 validate_grpc_listener(addr)?;
83 }
84 if matches!(auth, crate::listener::AuthConfig::Disabled)
85 && listener.is_public()
86 && !allow_loopback_bypass
87 {
88 return Err(anyhow!(
89 "auth is disabled but the listener is not loopback-bound; \
90 set AGENTGUARD_AUTH=apikey:<path> or AGENTGUARD_ALLOW_LOOPBACK_BYPASS=1"
91 ));
92 }
93 Ok(())
94}
95
96pub async fn run(cfg: ServerConfig) -> Result<()> {
103 let cache_config = DecisionCache::try_config_from_env()
104 .map_err(|error| anyhow!("invalid decision cache configuration: {error}"))?;
105 let audit_rotation = match std::env::var("AGENTGUARD_AUDIT_MAX_BYTES") {
106 Ok(value) => parse_audit_rotation(Some(&value))?,
107 Err(std::env::VarError::NotPresent) => parse_audit_rotation(None)?,
108 Err(std::env::VarError::NotUnicode(_)) => {
109 return Err(anyhow!("AGENTGUARD_AUDIT_MAX_BYTES must be valid Unicode"));
110 }
111 };
112 if let crate::listener::Listener::Tls { cert, key, .. } = &cfg.listener {
116 validate_tls_paths(cert, key)?;
117 }
118 let allow_loopback_bypass = std::env::var("AGENTGUARD_ALLOW_LOOPBACK_BYPASS")
119 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
120 .unwrap_or(false);
121 validate_listener_security(
122 &cfg.listener,
123 cfg.grpc_listener,
124 &cfg.auth,
125 allow_loopback_bypass,
126 )?;
127 if matches!(cfg.auth, crate::listener::AuthConfig::Disabled)
128 && cfg.listener.is_public()
129 && allow_loopback_bypass
130 {
131 tracing::warn!(
132 "AGENTGUARD_ALLOW_LOOPBACK_BYPASS=1: serving unauthenticated decisions on a public listener; \
133 this should only happen behind a trusted reverse proxy"
134 );
135 }
136 let auth = AuthLayer::from_config(&cfg.auth, allow_loopback_bypass)
137 .map_err(|e| anyhow!("auth layer: {}", e))?;
138 let key_watcher_target = auth.reloadable_key_store();
139 let chain_secret = match &cfg.chain_secret {
140 Some(path) => {
141 let bytes =
142 std::fs::read(path).map_err(|e| anyhow!("read chain secret {:?}: {}", path, e))?;
143 let bytes = decode_chain_secret(&bytes)
144 .ok_or_else(|| anyhow!("chain secret file {:?} is empty", path))?;
145 Some(bytes)
146 }
147 None => {
148 if cfg.audit_log.is_some() {
149 tracing::warn!(
150 "AGENTGUARD_CHAIN_SECRET is not set; audit log will be plain JSONL (no tamper evidence)"
151 );
152 }
153 None
154 }
155 };
156 let state = Arc::new(
157 build_state_with_options(
158 cfg.store_root.clone(),
159 cfg.audit_log.clone(),
160 chain_secret,
161 auth,
162 AppStateOptions {
163 cache: Some(cache_config),
164 audit_rotation,
165 },
166 )
167 .await
168 .map_err(|e| anyhow!("build state: {}", e))?,
169 );
170 let watcher_handle =
171 try_spawn_policy_watcher(cfg.store_root.clone(), state.clone() as Arc<dyn ReloadSink>)
172 .map_err(|error| anyhow!("initialize policy watcher: {error}"))?;
173 let key_watcher_handle =
174 key_watcher_target.map(|(path, store)| spawn_api_key_watcher(path, store));
175 let app = router((*state).clone());
176 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
177
178 let grpc_handle = if let Some(addr) = cfg.grpc_listener {
183 let svc = crate::grpc::service(state.clone());
184 tracing::info!("agentguard gRPC listening on tcp://{}", addr);
185 Some(tokio::spawn(async move {
186 let mut shutdown_rx = shutdown_rx;
187 let res = tonic::transport::Server::builder()
188 .add_service(svc)
189 .serve_with_shutdown(addr, async move {
190 if !*shutdown_rx.borrow() {
191 let _ = shutdown_rx.changed().await;
192 }
193 })
194 .await;
195 if let Err(e) = res {
196 tracing::error!(error = %e, "gRPC server exited with error");
197 }
198 }))
199 } else {
200 None
201 };
202
203 match cfg.listener.clone() {
204 Listener::Tcp(addr) => {
205 let listener = TcpListener::bind(addr).await?;
206 tracing::info!("agentguard listening on tcp://{}", addr);
207 serve(listener, app.into_make_service())
208 .with_graceful_shutdown(async move {
214 shutdown_signal_with_sighup(state.clone()).await;
215 let _ = shutdown_tx.send(true);
216 })
217 .await?;
218 }
219 Listener::Tls { addr, cert, key } => {
220 use axum_server::tls_rustls::RustlsConfig;
221 let cfg = RustlsConfig::from_pem_file(cert, key).await?;
222 tracing::info!("agentguard listening on tls://{}", addr);
223 let handle = axum_server::Handle::new();
227 let signal_handle = handle.clone();
228 let state_for_signal = state.clone();
229 let grpc_shutdown_tx = shutdown_tx.clone();
230 tokio::spawn(async move {
231 shutdown_signal_with_sighup(state_for_signal).await;
232 let _ = grpc_shutdown_tx.send(true);
233 signal_handle.shutdown();
234 });
235 axum_server::bind_rustls(addr, cfg)
236 .handle(handle)
237 .serve(app.into_make_service())
238 .await?;
239 }
240 }
241
242 watcher_handle.abort();
243 if let Some(handle) = key_watcher_handle {
244 handle.abort();
245 }
246 if let Some(h) = grpc_handle {
247 if let Err(error) = h.await {
248 tracing::error!(%error, "gRPC shutdown task failed");
249 }
250 }
251 tracing::info!("agentguard stopped cleanly");
252 Ok(())
253}
254
255pub async fn build_router(
264 cfg: ServerConfig,
265 allow_loopback_bypass: bool,
266) -> Result<(axum::Router, Arc<crate::authzen::AppState>)> {
267 let auth = AuthLayer::from_config(&cfg.auth, allow_loopback_bypass)
268 .map_err(|e| anyhow!("auth layer: {}", e))?;
269 let chain_secret = match &cfg.chain_secret {
270 Some(path) => {
271 let bytes =
272 std::fs::read(path).map_err(|e| anyhow!("read chain secret {:?}: {}", path, e))?;
273 decode_chain_secret(&bytes)
274 }
275 None => None,
276 };
277 let state = build_state(
278 cfg.store_root.clone(),
279 cfg.audit_log.clone(),
280 chain_secret,
281 auth,
282 )
283 .await
284 .map_err(|e| anyhow!("build state: {}", e))?;
285 let app = router(state.clone());
286 Ok((app, Arc::new(state)))
287}
288
289pub trait ReloadSink: Send + Sync + 'static {
292 fn reload(&self);
294}
295
296impl ReloadSink for crate::authzen::AppState {
297 fn reload(&self) {
298 match self.authorizer().reload() {
299 Ok(()) => {
300 self.metrics().record_policy_reload();
301 tracing::info!("policy snapshot reloaded");
302 }
303 Err(error) => {
304 tracing::error!(%error, "policy reload failed; retaining last known-good snapshot");
305 }
306 }
307 }
308}
309
310pub fn try_spawn_policy_watcher(
314 store_root: std::path::PathBuf,
315 sink: Arc<dyn ReloadSink>,
316) -> std::io::Result<tokio::task::JoinHandle<()>> {
317 let watcher = policy_watch(&store_root, Duration::from_millis(250))?;
318 Ok(spawn_policy_watcher_task(watcher, sink))
319}
320
321pub fn spawn_policy_watcher(
324 store_root: std::path::PathBuf,
325 sink: Arc<dyn ReloadSink>,
326) -> tokio::task::JoinHandle<()> {
327 match policy_watch(&store_root, Duration::from_millis(250)) {
328 Ok(watcher) => spawn_policy_watcher_task(watcher, sink),
329 Err(error) => tokio::spawn(async move {
330 tracing::error!(
331 store_root = %store_root.display(),
332 error = %error,
333 "policy watcher init failed; hot reload disabled"
334 );
335 }),
336 }
337}
338
339fn spawn_policy_watcher_task(
340 mut watcher: PolicyWatcher,
341 sink: Arc<dyn ReloadSink>,
342) -> tokio::task::JoinHandle<()> {
343 tokio::spawn(async move {
344 let mut interval = tokio::time::interval(Duration::from_millis(500));
345 loop {
346 interval.tick().await;
347 let events = watcher.events();
348 for error in watcher.take_errors() {
349 tracing::error!(%error, "policy filesystem watcher failed");
350 }
351 if events.is_empty() {
352 continue;
353 }
354 sink.reload();
355 tracing::info!(events = events.len(), "policy reload triggered by watcher");
356 }
357 })
358}
359
360pub fn spawn_api_key_watcher(
366 path: std::path::PathBuf,
367 store: Arc<agentguard_auth::ApiKeyStore>,
368) -> tokio::task::JoinHandle<()> {
369 let mut last_attempted_snapshot = std::fs::read(&path).ok();
372 if let Err(error) = store.reload_from_file(&path) {
373 tracing::error!(
374 key_store = %path.display(),
375 %error,
376 "initial API-key store refresh failed; retaining last known-good key set"
377 );
378 }
379 tokio::spawn(async move {
380 let mut interval = tokio::time::interval(Duration::from_millis(250));
381 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
382 let mut last_read_error_log = None;
383 loop {
384 interval.tick().await;
385 match std::fs::read(&path) {
386 Ok(snapshot) => {
387 last_read_error_log = None;
388 if last_attempted_snapshot.as_ref() != Some(&snapshot) {
389 match store.reload_from_file(&path) {
390 Ok(()) => tracing::info!(
391 key_store = %path.display(),
392 "API-key store reloaded"
393 ),
394 Err(error) => tracing::error!(
395 key_store = %path.display(),
396 %error,
397 "API-key store reload failed; retaining last known-good key set"
398 ),
399 }
400 last_attempted_snapshot = Some(snapshot);
403 }
404 }
405 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
406 if last_attempted_snapshot.take().is_some() {
407 match store.reload_from_file(&path) {
408 Ok(()) => tracing::info!(
409 key_store = %path.display(),
410 "API-key store removed; active keys cleared"
411 ),
412 Err(error) => tracing::error!(
413 key_store = %path.display(),
414 %error,
415 "API-key store removal reload failed; retaining last known-good key set"
416 ),
417 }
418 }
419 }
420 Err(error) => {
421 let should_log = last_read_error_log
422 .map(|last: std::time::Instant| last.elapsed() >= Duration::from_secs(30))
423 .unwrap_or(true);
424 if should_log {
425 tracing::error!(
426 key_store = %path.display(),
427 %error,
428 "API-key store read failed; retaining last known-good key set"
429 );
430 last_read_error_log = Some(std::time::Instant::now());
431 }
432 }
433 }
434 }
435 })
436}
437
438pub async fn shutdown_signal_with_sighup(state: Arc<crate::authzen::AppState>) {
444 use tokio::signal::unix as u;
445
446 #[cfg(unix)]
450 let (mut terminate, mut sighup) = {
451 let t = u::signal(u::SignalKind::terminate()).ok();
452 let h = u::signal(u::SignalKind::hangup()).ok();
453 (t, h)
454 };
455 #[cfg(not(unix))]
456 let (mut terminate, mut sighup): (Option<Never>, Option<Never>) = (None, None);
457
458 loop {
459 tokio::select! {
461 res = signal::ctrl_c() => {
465 if res.is_err() {
466 std::future::pending::<()>().await;
467 } else {
468 tracing::info!("SIGINT received, draining");
469 break;
470 }
471 }
472 _ = async {
473 match terminate.as_mut() {
474 Some(s) => { let _ = s.recv().await; }
475 None => std::future::pending::<()>().await,
476 }
477 } => {
478 tracing::info!("SIGTERM received, draining");
479 break;
480 }
481 _ = async {
482 match sighup.as_mut() {
483 Some(s) => { let _ = s.recv().await; }
484 None => std::future::pending::<()>().await,
485 }
486 } => {
487 match state.authorizer().reload() {
488 Ok(()) => state.metrics().record_policy_reload(),
489 Err(error) => tracing::error!(%error, "policy reload failed after SIGHUP; retaining last known-good snapshot"),
490 }
491 tracing::info!(
492 "SIGHUP received; cache invalidated, awaiting actual shutdown"
493 );
494 }
496 }
497 }
498}
499
500#[cfg(not(unix))]
503type Never = std::convert::Infallible;
504
505#[cfg(test)]
506mod tls_validation_tests {
507 use super::{
508 parse_audit_rotation, run, validate_grpc_listener, validate_listener_security,
509 validate_tls_paths,
510 };
511 use crate::auth_layer::AuthLayer;
512 use crate::listener::{AuthConfig, Listener, ServerConfig};
513 use std::io::Write;
514 use std::sync::Arc;
515
516 struct NoopReloadSink;
517
518 impl super::ReloadSink for NoopReloadSink {
519 fn reload(&self) {}
520 }
521
522 #[tokio::test]
523 async fn run_rejects_invalid_tls_material_before_loading_the_policy_store() {
524 let dir = tempfile::tempdir().unwrap();
525 let cfg = ServerConfig {
526 listener: Listener::Tls {
527 addr: "127.0.0.1:0".parse().unwrap(),
528 cert: dir.path().join("missing-cert.pem"),
529 key: dir.path().join("missing-key.pem"),
530 },
531 store_root: dir.path().join("missing-policy-store"),
532 audit_log: None,
533 chain_secret: None,
534 auth: AuthConfig::Disabled,
535 grpc_listener: None,
536 };
537
538 let error = run(cfg).await.unwrap_err().to_string();
539 assert!(
540 error.contains("tls cert"),
541 "unexpected startup error: {error}"
542 );
543 assert!(!dir.path().join("missing-policy-store").exists());
544 }
545
546 #[tokio::test]
547 async fn run_rejects_empty_audit_chain_secret_before_loading_the_policy_store() {
548 let dir = tempfile::tempdir().unwrap();
549 let secret = dir.path().join("chain-secret");
550 std::fs::write(&secret, b"").unwrap();
551 let cfg = ServerConfig {
552 listener: Listener::Tcp("127.0.0.1:0".parse().unwrap()),
553 store_root: dir.path().join("missing-policy-store"),
554 audit_log: Some(dir.path().join("audit.jsonl")),
555 chain_secret: Some(secret),
556 auth: AuthConfig::Disabled,
557 grpc_listener: None,
558 };
559
560 let error = run(cfg).await.unwrap_err().to_string();
561 assert!(
562 error.contains("chain secret file"),
563 "unexpected startup error: {error}"
564 );
565 assert!(!dir.path().join("missing-policy-store").exists());
566 }
567
568 #[test]
569 fn missing_cert_reports_helpful_error() {
570 let dir = tempfile::tempdir().unwrap();
571 let cert = dir.path().join("does-not-exist.pem");
572 let key = dir.path().join("also-missing.pem");
573 let err = validate_tls_paths(&cert, &key).unwrap_err().to_string();
574 assert!(err.contains("tls cert"), "missing label: {err}");
575 assert!(
576 err.contains("not readable"),
577 "missing actionable reason: {err}"
578 );
579 }
580
581 #[test]
582 fn empty_cert_reports_helpful_error() {
583 let dir = tempfile::tempdir().unwrap();
584 let cert_path = dir.path().join("cert.pem");
585 let key_path = dir.path().join("key.pem");
586 std::fs::File::create(&cert_path).unwrap();
587 std::fs::File::create(&key_path)
588 .unwrap()
589 .write_all(b"x")
590 .unwrap();
591 let err = validate_tls_paths(&cert_path, &key_path)
592 .unwrap_err()
593 .to_string();
594 assert!(err.contains("cert"), "missing cert label: {err}");
595 assert!(err.contains("empty"), "missing size reason: {err}");
596 }
597
598 #[test]
599 fn directory_instead_of_file_reports_helpful_error() {
600 let dir = tempfile::tempdir().unwrap();
601 let err = validate_tls_paths(dir.path(), dir.path())
602 .unwrap_err()
603 .to_string();
604 assert!(
605 err.contains("not a regular file"),
606 "missing dir reason: {err}"
607 );
608 }
609
610 #[test]
611 fn plaintext_grpc_listener_is_restricted_to_loopback() {
612 assert!(validate_grpc_listener("127.0.0.1:9443".parse().unwrap()).is_ok());
613 assert!(validate_grpc_listener("[::1]:9443".parse().unwrap()).is_ok());
614 let err = validate_grpc_listener("0.0.0.0:9443".parse().unwrap())
615 .unwrap_err()
616 .to_string();
617 assert!(err.contains("loopback-bound"));
618 }
619
620 #[test]
621 fn audit_rotation_requires_a_positive_integer_when_configured() {
622 assert!(parse_audit_rotation(None).unwrap().is_none());
623 assert_eq!(
624 parse_audit_rotation(Some("1048576"))
625 .unwrap()
626 .unwrap()
627 .max_bytes,
628 1_048_576
629 );
630 assert!(parse_audit_rotation(Some("0")).is_err());
631 assert!(parse_audit_rotation(Some("many")).is_err());
632 }
633
634 #[test]
635 fn fallible_policy_watcher_rejects_missing_store_root() {
636 let dir = tempfile::tempdir().unwrap();
637 let error = super::try_spawn_policy_watcher(
638 dir.path().join("missing-store"),
639 Arc::new(NoopReloadSink),
640 )
641 .unwrap_err();
642 assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
643 }
644
645 #[tokio::test]
646 async fn filesystem_watcher_reloads_nested_policy_files() {
647 use std::time::Duration;
648
649 let dir = tempfile::tempdir().unwrap();
650 let store = agentguard_core::PolicyStore::open(dir.path()).unwrap();
651 store
652 .write_policy("initial", "permit(principal, action, resource);")
653 .unwrap();
654 let state =
655 crate::authzen::build_state(dir.path().to_path_buf(), None, None, AuthLayer::Disabled)
656 .await
657 .unwrap();
658 assert_eq!(state.authorizer().policy_count(), 1);
659
660 let watcher =
661 super::try_spawn_policy_watcher(dir.path().to_path_buf(), Arc::new(state.clone()))
662 .unwrap();
663 tokio::time::sleep(Duration::from_millis(100)).await;
664 let nested_policies = store.policies_dir().join("team");
665 std::fs::create_dir_all(&nested_policies).unwrap();
666 std::fs::write(
667 nested_policies.join("second.cedar"),
668 "forbid(principal, action, resource);",
669 )
670 .unwrap();
671
672 tokio::time::timeout(Duration::from_secs(4), async {
673 while state.authorizer().policy_count() != 2 {
674 tokio::time::sleep(Duration::from_millis(50)).await;
675 }
676 })
677 .await
678 .expect("nested subdirectory policy edit should reload the complete policy snapshot");
679 watcher.abort();
680 }
681
682 #[test]
683 fn unauthenticated_public_listener_requires_explicit_bypass() {
684 let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
685 let error =
686 validate_listener_security(&listener, None, &AuthConfig::Disabled, false).unwrap_err();
687 assert!(error.to_string().contains("auth is disabled"));
688 }
689
690 #[test]
691 fn explicit_bypass_only_relaxes_http_auth_guard() {
692 let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
693 assert!(validate_listener_security(&listener, None, &AuthConfig::Disabled, true).is_ok());
694 assert!(validate_listener_security(
695 &listener,
696 Some("0.0.0.0:9443".parse().unwrap()),
697 &AuthConfig::Disabled,
698 true
699 )
700 .is_err());
701 }
702
703 #[test]
704 fn api_key_auth_allows_public_listener_without_bypass() {
705 let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
706 let auth = AuthConfig::ApiKey {
707 path: "keys.json".into(),
708 };
709 assert!(validate_listener_security(&listener, None, &auth, false).is_ok());
710 }
711}