Skip to main content

nono_proxy/
config.rs

1//! Proxy configuration types.
2//!
3//! Defines the configuration for the proxy server, including allowed hosts,
4//! credential routes, and external proxy settings.
5
6use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8
9/// Credential injection mode determining how credentials are inserted into requests.
10#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum InjectMode {
13    /// Inject credential into an HTTP header (default)
14    #[default]
15    Header,
16    /// Replace a pattern in the URL path with the credential
17    UrlPath,
18    /// Add or replace a query parameter with the credential
19    QueryParam,
20    /// Use HTTP Basic Authentication (credential format: "username:password")
21    BasicAuth,
22}
23
24/// Configuration for the proxy server.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ProxyConfig {
27    /// Bind address (default: 127.0.0.1)
28    #[serde(default = "default_bind_addr")]
29    pub bind_addr: IpAddr,
30
31    /// Bind port (0 = OS-assigned ephemeral port)
32    #[serde(default)]
33    pub bind_port: u16,
34
35    /// Allowed hosts for CONNECT mode (exact match + wildcards).
36    /// Empty = allow all hosts (except deny list).
37    #[serde(default)]
38    pub allowed_hosts: Vec<String>,
39
40    /// Reverse proxy credential routes.
41    #[serde(default)]
42    pub routes: Vec<RouteConfig>,
43
44    /// External (enterprise) proxy URL for passthrough mode.
45    /// When set, CONNECT requests are chained to this proxy.
46    #[serde(default)]
47    pub external_proxy: Option<ExternalProxyConfig>,
48
49    /// Maximum concurrent connections (0 = unlimited).
50    #[serde(default)]
51    pub max_connections: usize,
52}
53
54impl Default for ProxyConfig {
55    fn default() -> Self {
56        Self {
57            bind_addr: default_bind_addr(),
58            bind_port: 0,
59            allowed_hosts: Vec::new(),
60            routes: Vec::new(),
61            external_proxy: None,
62            max_connections: 256,
63        }
64    }
65}
66
67fn default_bind_addr() -> IpAddr {
68    IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
69}
70
71/// Configuration for a reverse proxy credential route.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RouteConfig {
74    /// Path prefix for routing (e.g., "/openai")
75    pub prefix: String,
76
77    /// Upstream URL to forward to (e.g., "https://api.openai.com")
78    pub upstream: String,
79
80    /// Keystore account name to load the credential from.
81    /// If `None`, no credential is injected.
82    pub credential_key: Option<String>,
83
84    /// Injection mode (default: "header")
85    #[serde(default)]
86    pub inject_mode: InjectMode,
87
88    // --- Header mode fields ---
89    /// HTTP header name for the credential (default: "Authorization")
90    /// Only used when inject_mode is "header".
91    #[serde(default = "default_inject_header")]
92    pub inject_header: String,
93
94    /// Format string for the credential value. `{}` is replaced with the secret.
95    /// Default: "Bearer {}"
96    /// Only used when inject_mode is "header".
97    #[serde(default = "default_credential_format")]
98    pub credential_format: String,
99
100    // --- URL path mode fields ---
101    /// Pattern to match in incoming URL path. Use {} as placeholder for phantom token.
102    /// Example: "/bot{}/" matches "/bot<token>/getMe"
103    /// Only used when inject_mode is "url_path".
104    #[serde(default)]
105    pub path_pattern: Option<String>,
106
107    /// Pattern for outgoing URL path. Use {} as placeholder for real credential.
108    /// Defaults to same as path_pattern if not specified.
109    /// Only used when inject_mode is "url_path".
110    #[serde(default)]
111    pub path_replacement: Option<String>,
112
113    // --- Query param mode fields ---
114    /// Name of the query parameter to add/replace with the credential.
115    /// Only used when inject_mode is "query_param".
116    #[serde(default)]
117    pub query_param_name: Option<String>,
118
119    /// Explicit environment variable name for the phantom token (e.g., "OPENAI_API_KEY").
120    ///
121    /// When set, this is used as the SDK API key env var name instead of deriving
122    /// it from `credential_key.to_uppercase()`. Required when `credential_key` is
123    /// a URI manager reference (e.g., `op://`, `apple-password://`) which would
124    /// otherwise produce a nonsensical env var name.
125    #[serde(default)]
126    pub env_var: Option<String>,
127}
128
129fn default_inject_header() -> String {
130    "Authorization".to_string()
131}
132
133fn default_credential_format() -> String {
134    "Bearer {}".to_string()
135}
136
137/// Configuration for an external (enterprise) proxy.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ExternalProxyConfig {
140    /// Proxy address (e.g., "squid.corp.internal:3128")
141    pub address: String,
142
143    /// Optional authentication for the external proxy.
144    pub auth: Option<ExternalProxyAuth>,
145
146    /// Hosts to bypass the external proxy and route directly.
147    /// Supports exact hostnames and `*.` wildcard suffixes (case-insensitive).
148    /// Empty = all traffic goes through the external proxy.
149    #[serde(default)]
150    pub bypass_hosts: Vec<String>,
151}
152
153/// Authentication for an external proxy.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ExternalProxyAuth {
156    /// Keystore account name for proxy credentials.
157    pub keyring_account: String,
158
159    /// Authentication scheme (only "basic" supported).
160    #[serde(default = "default_auth_scheme")]
161    pub scheme: String,
162}
163
164fn default_auth_scheme() -> String {
165    "basic".to_string()
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_default_config() {
175        let config = ProxyConfig::default();
176        assert_eq!(config.bind_addr, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
177        assert_eq!(config.bind_port, 0);
178        assert!(config.allowed_hosts.is_empty());
179        assert!(config.routes.is_empty());
180        assert!(config.external_proxy.is_none());
181    }
182
183    #[test]
184    fn test_config_serialization() {
185        let config = ProxyConfig {
186            allowed_hosts: vec!["api.openai.com".to_string()],
187            ..Default::default()
188        };
189        let json = serde_json::to_string(&config).unwrap();
190        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
191        assert_eq!(deserialized.allowed_hosts, vec!["api.openai.com"]);
192    }
193
194    #[test]
195    fn test_external_proxy_config_with_bypass_hosts() {
196        let config = ProxyConfig {
197            external_proxy: Some(ExternalProxyConfig {
198                address: "squid.corp:3128".to_string(),
199                auth: None,
200                bypass_hosts: vec!["internal.corp".to_string(), "*.private.net".to_string()],
201            }),
202            ..Default::default()
203        };
204        let json = serde_json::to_string(&config).unwrap();
205        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
206        let ext = deserialized.external_proxy.unwrap();
207        assert_eq!(ext.address, "squid.corp:3128");
208        assert_eq!(ext.bypass_hosts.len(), 2);
209        assert_eq!(ext.bypass_hosts[0], "internal.corp");
210        assert_eq!(ext.bypass_hosts[1], "*.private.net");
211    }
212
213    #[test]
214    fn test_external_proxy_config_bypass_hosts_default_empty() {
215        let json = r#"{"address": "proxy:3128", "auth": null}"#;
216        let ext: ExternalProxyConfig = serde_json::from_str(json).unwrap();
217        assert!(ext.bypass_hosts.is_empty());
218    }
219}