Skip to main content

vm_proxy/
config.rs

1use std::collections::HashMap;
2use std::net::Ipv4Addr;
3use std::path::PathBuf;
4use std::time::Duration;
5
6/// A host port exposed to the guest via host.vm.internal.
7#[derive(Debug, Clone)]
8pub struct ExposeHostMapping {
9    /// Port on the host (127.0.0.1:host_port).
10    pub host_port: u16,
11    /// Port the guest connects to (host.vm.internal:guest_port).
12    pub guest_port: u16,
13}
14
15/// Configuration for the proxy engine.
16#[derive(Debug, Clone)]
17pub struct ProxyConfig {
18    /// IPv4 DNS server used for upstream queries.
19    pub dns_resolver: Ipv4Addr,
20    /// Secrets to inject. Key is the env var name visible to the guest.
21    /// The guest gets a random placeholder token; the proxy substitutes
22    /// the real value only when the request targets an allowed host.
23    pub secrets: HashMap<String, SecretConfig>,
24    /// Network access rules.
25    pub network: NetworkConfig,
26    /// Host ports exposed to the guest via host.vm.internal.
27    pub expose_host: Vec<ExposeHostMapping>,
28    /// Working directory for secret provider commands. Normally the directory
29    /// holding the resolved vm.json, so relative paths in a config behave
30    /// the same wherever hanzo-vm is invoked from. None inherits the process cwd.
31    pub config_dir: Option<PathBuf>,
32}
33
34impl Default for ProxyConfig {
35    fn default() -> Self {
36        Self {
37            dns_resolver: Ipv4Addr::new(8, 8, 8, 8),
38            secrets: HashMap::new(),
39            network: NetworkConfig::default(),
40            expose_host: Vec::new(),
41            config_dir: None,
42        }
43    }
44}
45
46/// A secret that the proxy injects into HTTP requests.
47///
48/// The value comes from exactly one source: a host environment variable
49/// (`from`), a command that mints it (`command`), or a literal (`value`).
50#[derive(Debug, Clone, Default)]
51pub struct SecretConfig {
52    /// Host environment variable to read the real value from.
53    pub from: Option<String>,
54    /// Command to run to mint the value, as argv. Never passed to a shell.
55    /// Re-run once the minted value approaches its expiry.
56    pub command: Option<Vec<String>>,
57    /// Lifetime to assume when a command reports no expiry of its own.
58    pub ttl: Option<Duration>,
59    /// Domain patterns where this secret may be sent (e.g., "api.openai.com").
60    /// The proxy only substitutes the placeholder on requests to these hosts.
61    pub hosts: Vec<String>,
62    /// If set, use this value directly instead of reading from the host env var.
63    pub value: Option<String>,
64}
65
66impl SecretConfig {
67    /// Secret backed by a host environment variable.
68    pub fn from_env(var: impl Into<String>, hosts: Vec<String>) -> Self {
69        Self {
70            from: Some(var.into()),
71            hosts,
72            ..Default::default()
73        }
74    }
75
76    /// Secret minted by running a command.
77    pub fn from_command(command: Vec<String>, hosts: Vec<String>) -> Self {
78        Self {
79            command: Some(command),
80            hosts,
81            ..Default::default()
82        }
83    }
84
85    /// True if this secret is bound to `domain`.
86    pub fn matches_domain(&self, domain: &str) -> bool {
87        self.hosts
88            .iter()
89            .any(|pattern| domain_matches(pattern, domain))
90    }
91}
92
93/// Network access policy.
94#[derive(Debug, Clone, Default)]
95pub struct NetworkConfig {
96    /// Allowed domain patterns. Empty = allow all.
97    /// Supports wildcards: "*.openai.com", "registry.npmjs.org".
98    pub allow: Vec<String>,
99}
100
101impl NetworkConfig {
102    /// Returns true if a domain allowlist is configured.
103    pub fn has_allowlist(&self) -> bool {
104        !self.allow.is_empty()
105    }
106}
107
108impl ProxyConfig {
109    /// Check if a domain is allowed by the network policy.
110    /// Empty allowlist means all domains are allowed.
111    pub fn is_domain_allowed(&self, domain: &str) -> bool {
112        if self.network.allow.is_empty() {
113            return true;
114        }
115        self.network
116            .allow
117            .iter()
118            .any(|pattern| domain_matches(pattern, domain))
119    }
120
121    /// Look up whether a connection to the gateway IP on `guest_port` should
122    /// be forwarded to a host port. Returns the host port if matched.
123    pub fn exposed_host_port(&self, dst_ip: Ipv4Addr, guest_port: u16) -> Option<u16> {
124        const GATEWAY: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
125        if dst_ip != GATEWAY {
126            return None;
127        }
128        self.expose_host
129            .iter()
130            .find(|m| m.guest_port == guest_port)
131            .map(|m| m.host_port)
132    }
133}
134
135/// Simple wildcard domain matching.
136/// "*" matches any domain (catch-all).
137/// "*.example.com" matches "api.example.com" but not "example.com".
138/// "example.com" matches exactly "example.com".
139pub(crate) fn domain_matches(pattern: &str, domain: &str) -> bool {
140    if pattern == "*" {
141        true
142    } else if let Some(suffix) = pattern.strip_prefix("*.") {
143        domain.ends_with(suffix)
144            && domain.len() > suffix.len()
145            && domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.'
146    } else {
147        pattern == domain
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_exposed_host_port() {
157        use std::net::Ipv4Addr;
158        let config = ProxyConfig {
159            expose_host: vec![
160                ExposeHostMapping {
161                    host_port: 3000,
162                    guest_port: 8080,
163                },
164                ExposeHostMapping {
165                    host_port: 5432,
166                    guest_port: 5432,
167                },
168            ],
169            ..Default::default()
170        };
171        // Gateway IP match
172        assert_eq!(
173            config.exposed_host_port(Ipv4Addr::new(10, 0, 0, 1), 8080),
174            Some(3000)
175        );
176        assert_eq!(
177            config.exposed_host_port(Ipv4Addr::new(10, 0, 0, 1), 5432),
178            Some(5432)
179        );
180        // No mapping for this port
181        assert_eq!(
182            config.exposed_host_port(Ipv4Addr::new(10, 0, 0, 1), 9999),
183            None
184        );
185        // Non-gateway IP
186        assert_eq!(
187            config.exposed_host_port(Ipv4Addr::new(1, 2, 3, 4), 8080),
188            None
189        );
190    }
191
192    #[test]
193    fn test_domain_matching() {
194        assert!(domain_matches("*", "anything.com"));
195        assert!(domain_matches("*", "api.example.com"));
196        assert!(domain_matches("example.com", "example.com"));
197        assert!(!domain_matches("example.com", "api.example.com"));
198        assert!(domain_matches("*.example.com", "api.example.com"));
199        assert!(domain_matches("*.example.com", "deep.api.example.com"));
200        assert!(!domain_matches("*.example.com", "example.com"));
201        assert!(!domain_matches("*.example.com", "notexample.com"));
202    }
203
204    #[test]
205    fn test_has_allowlist() {
206        let empty = NetworkConfig::default();
207        assert!(!empty.has_allowlist());
208
209        let with_entries = NetworkConfig {
210            allow: vec!["api.example.com".into()],
211        };
212        assert!(with_entries.has_allowlist());
213    }
214
215    #[test]
216    fn default_dns_resolver_is_google_public_dns() {
217        assert_eq!(
218            ProxyConfig::default().dns_resolver,
219            Ipv4Addr::new(8, 8, 8, 8)
220        );
221    }
222}