//! `ops-agent` — the on-host executor. //! //! Reads its config (its own grant + the caller identities it trusts), binds //! the configured tailnet socket, and serves `/run` `/health` `/pull`. On //! macOS it is installed as an Aqua LaunchAgent so build+sign run in the GUI //! security session (see `_private/docs/ops-core/executor.md`). No root needed: //! `launchctl bootstrap gui/$(id -u) `. //! //! Usage: `ops-agent --config /path/to/config.toml` use anyhow::{Context, Result}; use ops_exec::agent::{AgentConfig, AgentState, router}; use std::net::{IpAddr, SocketAddr}; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(env_filter()) .init(); let config_path = parse_config_arg().context("usage: ops-agent --config ")?; let raw = std::fs::read_to_string(&config_path) .with_context(|| format!("reading config {config_path}"))?; let config: AgentConfig = toml::from_str(&raw).context("parsing config toml")?; let listen = config.listen; // The agent runs arbitrary commands under a caller's grant and can read // files via /pull; its only perimeter is the tailnet allow-list. Refuse to // bind a public interface so the allow-list can never be bypassed by a route // off the tailnet (a misconfigured `listen = "0.0.0.0:..."` is a hard error). ensure_tailnet_or_loopback(listen.ip()) .with_context(|| format!("invalid listen address {listen}"))?; tracing::info!(%listen, allow = config.allow.len(), "ops-agent starting"); let state = AgentState::new(config); let app = router(state); let listener = tokio::net::TcpListener::bind(listen) .await .with_context(|| format!("binding {listen}"))?; axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await .context("serving")?; Ok(()) } /// Permit only loopback or tailnet addresses: Tailscale's CGNAT range /// `100.64.0.0/10` (IPv4) and ULA `fc00::/7` (covers Tailscale's `fd7a::/16`). /// A public or unspecified (`0.0.0.0`) bind is rejected. fn ensure_tailnet_or_loopback(ip: IpAddr) -> Result<()> { let ok = match ip { IpAddr::V4(v4) => { let o = v4.octets(); v4.is_loopback() || (o[0] == 100 && (64..=127).contains(&o[1])) } IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00, }; anyhow::ensure!( ok, "ops-agent must listen on a tailnet (100.64.0.0/10 or fc00::/7) or loopback address, \ never a public or unspecified interface; got {ip}" ); Ok(()) } fn env_filter() -> tracing_subscriber::EnvFilter { tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) } /// Minimal `--config ` parser (no clap dependency for one flag). fn parse_config_arg() -> Option { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--config" | "-c" => return args.next(), other if other.starts_with("--config=") => { return Some(other.trim_start_matches("--config=").to_string()); } _ => {} } } None } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_loopback_and_tailnet_rejects_public() { let ok = |s: &str| ensure_tailnet_or_loopback(s.parse().unwrap()).is_ok(); assert!(ok("127.0.0.1")); assert!(ok("100.103.89.95")); // Tailscale CGNAT assert!(ok("::1")); assert!(ok("fd7a:115c:a1e0::1")); // Tailscale ULA assert!(!ok("0.0.0.0")); // unspecified — would expose off-tailnet assert!(!ok("192.168.1.10")); // LAN assert!(!ok("1.2.3.4")); // public } }