Skip to main content

max / makenotwork

3.8 KB · 105 lines History Blame Raw
1 //! `ops-agent` — the on-host executor.
2 //!
3 //! Reads its config (its own grant + the caller identities it trusts), binds
4 //! the configured tailnet socket, and serves `/run` `/health` `/pull`. On
5 //! macOS it is installed as an Aqua LaunchAgent so build+sign run in the GUI
6 //! security session (see `_private/docs/ops-core/executor.md`). No root needed:
7 //! `launchctl bootstrap gui/$(id -u) <plist>`.
8 //!
9 //! Usage: `ops-agent --config /path/to/config.toml`
10
11 use anyhow::{Context, Result};
12 use ops_exec::agent::{AgentConfig, AgentState, router};
13 use std::net::{IpAddr, SocketAddr};
14
15 #[tokio::main]
16 async fn main() -> Result<()> {
17 tracing_subscriber::fmt()
18 .with_env_filter(env_filter())
19 .init();
20
21 let config_path = parse_config_arg().context("usage: ops-agent --config <config.toml>")?;
22 let raw = std::fs::read_to_string(&config_path)
23 .with_context(|| format!("reading config {config_path}"))?;
24 let config: AgentConfig = toml::from_str(&raw).context("parsing config toml")?;
25 let listen = config.listen;
26
27 // The agent runs arbitrary commands under a caller's grant and can read
28 // files via /pull; its only perimeter is the tailnet allow-list. Refuse to
29 // bind a public interface so the allow-list can never be bypassed by a route
30 // off the tailnet (a misconfigured `listen = "0.0.0.0:..."` is a hard error).
31 ensure_tailnet_or_loopback(listen.ip())
32 .with_context(|| format!("invalid listen address {listen}"))?;
33
34 tracing::info!(%listen, allow = config.allow.len(), "ops-agent starting");
35 let state = AgentState::new(config);
36 let app = router(state);
37
38 let listener = tokio::net::TcpListener::bind(listen)
39 .await
40 .with_context(|| format!("binding {listen}"))?;
41 axum::serve(
42 listener,
43 app.into_make_service_with_connect_info::<SocketAddr>(),
44 )
45 .await
46 .context("serving")?;
47 Ok(())
48 }
49
50 /// Permit only loopback or tailnet addresses: Tailscale's CGNAT range
51 /// `100.64.0.0/10` (IPv4) and ULA `fc00::/7` (covers Tailscale's `fd7a::/16`).
52 /// A public or unspecified (`0.0.0.0`) bind is rejected.
53 fn ensure_tailnet_or_loopback(ip: IpAddr) -> Result<()> {
54 let ok = match ip {
55 IpAddr::V4(v4) => {
56 let o = v4.octets();
57 v4.is_loopback() || (o[0] == 100 && (64..=127).contains(&o[1]))
58 }
59 IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
60 };
61 anyhow::ensure!(
62 ok,
63 "ops-agent must listen on a tailnet (100.64.0.0/10 or fc00::/7) or loopback address, \
64 never a public or unspecified interface; got {ip}"
65 );
66 Ok(())
67 }
68
69 fn env_filter() -> tracing_subscriber::EnvFilter {
70 tracing_subscriber::EnvFilter::try_from_default_env()
71 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
72 }
73
74 /// Minimal `--config <path>` parser (no clap dependency for one flag).
75 fn parse_config_arg() -> Option<String> {
76 let mut args = std::env::args().skip(1);
77 while let Some(a) = args.next() {
78 match a.as_str() {
79 "--config" | "-c" => return args.next(),
80 other if other.starts_with("--config=") => {
81 return Some(other.trim_start_matches("--config=").to_string());
82 }
83 _ => {}
84 }
85 }
86 None
87 }
88
89 #[cfg(test)]
90 mod tests {
91 use super::*;
92
93 #[test]
94 fn accepts_loopback_and_tailnet_rejects_public() {
95 let ok = |s: &str| ensure_tailnet_or_loopback(s.parse().unwrap()).is_ok();
96 assert!(ok("127.0.0.1"));
97 assert!(ok("100.103.89.95")); // Tailscale CGNAT
98 assert!(ok("::1"));
99 assert!(ok("fd7a:115c:a1e0::1")); // Tailscale ULA
100 assert!(!ok("0.0.0.0")); // unspecified — would expose off-tailnet
101 assert!(!ok("192.168.1.10")); // LAN
102 assert!(!ok("1.2.3.4")); // public
103 }
104 }
105