Skip to main content

eggress_system_proxy/
apply.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use crate::command_runner::CommandRunner;
5use crate::inspection::{detect_platform, inspect_system_proxy_with_runner, SystemProxySettings};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CompatibilityProxyKind {
9    Http,
10    Socks5,
11}
12
13/// A structured system command. Programs and arguments are kept separate so
14/// that callers can pass them directly to `Command::new(program).args(args)`
15/// without relying on shell-style splitting, which would mishandle names
16/// containing spaces (e.g. Windows registry keys, macOS network services).
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18pub struct Command {
19    pub program: String,
20    pub args: Vec<String>,
21}
22
23impl Command {
24    pub fn new(program: impl Into<String>, args: Vec<String>) -> Self {
25        Self {
26            program: program.into(),
27            args,
28        }
29    }
30}
31
32impl fmt::Display for Command {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(f, "{}", self.program)?;
35        for arg in &self.args {
36            write!(f, " {}", arg)?;
37        }
38        Ok(())
39    }
40}
41
42/// Plan for applying system proxy settings.
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ApplyPlan {
45    /// Platform target.
46    pub platform: String,
47    /// Network service or scope (e.g., "*Wi-Fi" on macOS).
48    pub service: Option<String>,
49    /// HTTP proxy to set.
50    pub http_proxy: Option<String>,
51    /// HTTPS proxy to set.
52    pub https_proxy: Option<String>,
53    /// SOCKS proxy to set.
54    pub socks_proxy: Option<String>,
55    /// No-proxy/bypass list.
56    pub no_proxy: Option<String>,
57    /// Commands that would be executed.
58    pub commands: Vec<Command>,
59    /// Previous settings captured for rollback.
60    pub previous_settings: Option<SystemProxySettings>,
61}
62
63/// Rollback state saved before applying proxy changes.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct RollbackState {
66    /// Timestamp of when the rollback was created.
67    pub timestamp: String,
68    /// Platform target.
69    pub platform: String,
70    /// Network service or scope.
71    pub service: Option<String>,
72    /// Previous HTTP proxy.
73    pub http_proxy: Option<String>,
74    /// Previous HTTPS proxy.
75    pub https_proxy: Option<String>,
76    /// Previous SOCKS proxy.
77    pub socks_proxy: Option<String>,
78    /// Previous no-proxy/bypass list.
79    pub no_proxy: Option<String>,
80    /// Original ProxyEnable flag on Windows (true = enabled, false = disabled).
81    #[serde(default)]
82    pub proxy_enabled: Option<bool>,
83}
84
85/// In-memory rollback guard for compatibility `--sys`.
86pub struct AppliedProxy {
87    rollback: Option<RollbackState>,
88}
89
90impl AppliedProxy {
91    pub fn restore(&mut self) -> Result<(), String> {
92        let Some(rollback) = self.rollback.take() else {
93            return Ok(());
94        };
95        let runner = crate::command_runner::RealCommandRunner;
96        restore_with_runner(&rollback, &runner)
97    }
98
99    pub fn restore_with_runner(&mut self, runner: &dyn CommandRunner) -> Result<(), String> {
100        let Some(rollback) = self.rollback.take() else {
101            return Ok(());
102        };
103        restore_with_runner(&rollback, runner)
104    }
105}
106
107impl Drop for AppliedProxy {
108    fn drop(&mut self) {
109        let _ = self.restore();
110    }
111}
112
113/// Apply the compatibility system proxy using the platform backend and keep
114/// all rollback state in memory. This is deliberately separate from native
115/// `eggress system-proxy` inspection and from user-authored configuration.
116pub fn apply_compatibility_proxy(
117    kind: CompatibilityProxyKind,
118    address: std::net::SocketAddr,
119) -> Result<AppliedProxy, String> {
120    let runner = crate::command_runner::RealCommandRunner;
121    apply_compatibility_proxy_with_runner(kind, address, &runner)
122}
123
124pub fn apply_compatibility_proxy_with_runner(
125    kind: CompatibilityProxyKind,
126    address: std::net::SocketAddr,
127    runner: &dyn CommandRunner,
128) -> Result<AppliedProxy, String> {
129    // M-9: TOCTOU between inspect and apply is best-effort. Another process
130    // may modify proxy settings between inspection and `execute_apply`; the
131    // captured rollback reflects inspection-time values, mitigated by the
132    // RAII `AppliedProxy` guard. Re-inspecting immediately before execute
133    // would narrow the window but not eliminate it.
134    let platform = detect_platform();
135    let inspection = inspect_system_proxy_with_runner(runner);
136    if !inspection.apply_supported {
137        return Err(format!(
138            "system proxy apply is unavailable on platform '{platform}'"
139        ));
140    }
141    let settings = inspection
142        .settings
143        .ok_or_else(|| "unable to inspect current system proxy settings".to_string())?;
144    let endpoint = address.to_string();
145    let (http, https, socks) = match kind {
146        CompatibilityProxyKind::Http => (Some(endpoint.clone()), Some(endpoint), None),
147        CompatibilityProxyKind::Socks5 => (None, None, Some(endpoint)),
148    };
149    let plan = plan_apply(
150        &platform,
151        None,
152        http.as_deref(),
153        https.as_deref(),
154        socks.as_deref(),
155        None,
156        Some(&settings),
157    );
158    let rollback = create_rollback(&platform, plan.service.as_deref(), &settings);
159    if let Err(error) = execute_apply(&plan, runner) {
160        let rollback_error = restore_with_runner(&rollback, runner).err();
161        return Err(match rollback_error {
162            Some(rollback_error) => format!("{error}; rollback failed: {rollback_error}"),
163            None => error,
164        });
165    }
166    Ok(AppliedProxy {
167        rollback: Some(rollback),
168    })
169}
170
171fn restore_with_runner(rollback: &RollbackState, runner: &dyn CommandRunner) -> Result<(), String> {
172    for command in generate_revert_commands(rollback) {
173        let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
174        runner
175            .run(&command.program, &args)
176            .map_err(|error| format!("failed to restore '{command}': {error}"))?;
177    }
178    Ok(())
179}
180
181impl RollbackState {
182    /// Save rollback state to a JSON file.
183    pub fn save(&self, path: &PathBuf) -> Result<(), String> {
184        let json = serde_json::to_string_pretty(self)
185            .map_err(|e| format!("failed to serialize rollback state: {e}"))?;
186        use std::io::Write;
187        let mut options = std::fs::OpenOptions::new();
188        options.write(true).create_new(true);
189        #[cfg(unix)]
190        {
191            use std::os::unix::fs::OpenOptionsExt;
192            options.mode(0o600);
193        }
194        options
195            .open(path)
196            .and_then(|mut file| file.write_all(json.as_bytes()))
197            .map_err(|e| format!("failed to write rollback file: {e}"))
198    }
199
200    /// Load rollback state from a JSON file.
201    pub fn load(path: &PathBuf) -> Result<Self, String> {
202        let json = std::fs::read_to_string(path)
203            .map_err(|e| format!("failed to read rollback file: {e}"))?;
204        serde_json::from_str(&json).map_err(|e| format!("failed to parse rollback file: {e}"))
205    }
206}
207
208/// Create an apply plan without executing it (dry-run).
209pub fn plan_apply(
210    platform: &str,
211    service: Option<&str>,
212    http_proxy: Option<&str>,
213    https_proxy: Option<&str>,
214    socks_proxy: Option<&str>,
215    no_proxy: Option<&str>,
216    current_settings: Option<&SystemProxySettings>,
217) -> ApplyPlan {
218    let commands = match platform {
219        "macos" => {
220            let svc = service.unwrap_or("*Wi-Fi");
221            crate::backends::macos::generate_macos_apply_commands(
222                svc,
223                http_proxy,
224                https_proxy,
225                socks_proxy,
226                no_proxy,
227            )
228        }
229        "windows" => crate::backends::windows::generate_windows_apply_commands(
230            http_proxy,
231            https_proxy,
232            socks_proxy,
233            no_proxy,
234        ),
235        "linux" => crate::backends::linux::generate_gnome_apply_commands(
236            http_proxy,
237            https_proxy,
238            socks_proxy,
239            no_proxy,
240        ),
241        _ => Vec::new(),
242    };
243
244    ApplyPlan {
245        platform: platform.to_string(),
246        service: service.map(|s| s.to_string()),
247        http_proxy: http_proxy.map(|s| s.to_string()),
248        https_proxy: https_proxy.map(|s| s.to_string()),
249        socks_proxy: socks_proxy.map(|s| s.to_string()),
250        no_proxy: no_proxy.map(|s| s.to_string()),
251        commands,
252        previous_settings: current_settings.cloned(),
253    }
254}
255
256/// Create a rollback state from current settings.
257pub fn create_rollback(
258    platform: &str,
259    service: Option<&str>,
260    settings: &SystemProxySettings,
261) -> RollbackState {
262    let proxy_enabled = settings.raw.get("ProxyEnable").map(|v| v == "1");
263    RollbackState {
264        timestamp: chrono_timestamp(),
265        platform: platform.to_string(),
266        service: service.map(|s| s.to_string()),
267        http_proxy: settings.http_proxy.clone(),
268        https_proxy: settings.https_proxy.clone(),
269        socks_proxy: settings.socks_proxy.clone(),
270        no_proxy: settings.no_proxy.clone(),
271        proxy_enabled,
272    }
273}
274
275/// Execute an apply plan using the provided command runner.
276pub fn execute_apply(plan: &ApplyPlan, runner: &dyn CommandRunner) -> Result<Vec<Command>, String> {
277    let mut executed = Vec::new();
278    for cmd in &plan.commands {
279        let arg_refs: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
280        runner
281            .run(&cmd.program, &arg_refs)
282            .map_err(|e| format!("failed to execute '{cmd}': {e}"))?;
283        executed.push(cmd.clone());
284    }
285    Ok(executed)
286}
287
288/// Generate revert commands from rollback state.
289pub fn generate_revert_commands(rollback: &RollbackState) -> Vec<Command> {
290    match rollback.platform.as_str() {
291        "macos" => {
292            let svc = rollback.service.as_deref().unwrap_or("*Wi-Fi");
293            let mut commands = crate::backends::macos::generate_macos_disable_commands(svc);
294            if let Some(ref http) = rollback.http_proxy {
295                if !http.is_empty() {
296                    commands.push(Command::new(
297                        "networksetup",
298                        vec!["-setwebproxy".into(), svc.into(), "on".into()],
299                    ));
300                    commands.push(Command::new(
301                        "networksetup",
302                        vec!["-setwebproxyservers".into(), svc.into(), http.clone()],
303                    ));
304                }
305            }
306            if let Some(ref https) = rollback.https_proxy {
307                if !https.is_empty() {
308                    commands.push(Command::new(
309                        "networksetup",
310                        vec!["-setsecurewebproxy".into(), svc.into(), "on".into()],
311                    ));
312                    commands.push(Command::new(
313                        "networksetup",
314                        vec![
315                            "-setsecurewebproxyservers".into(),
316                            svc.into(),
317                            https.clone(),
318                        ],
319                    ));
320                }
321            }
322            if let Some(ref socks) = rollback.socks_proxy {
323                if !socks.is_empty() {
324                    commands.push(Command::new(
325                        "networksetup",
326                        vec!["-setsocksfirewallproxy".into(), svc.into(), "on".into()],
327                    ));
328                    commands.push(Command::new(
329                        "networksetup",
330                        vec![
331                            "-setsocksfirewallproxyserver".into(),
332                            svc.into(),
333                            socks.clone(),
334                        ],
335                    ));
336                }
337            }
338            commands
339        }
340        "windows" => {
341            let mut commands = crate::backends::windows::generate_windows_disable_commands();
342            let mut parts = Vec::new();
343            if let Some(ref http) = rollback.http_proxy {
344                parts.push(format!("http={http}"));
345            }
346            if let Some(ref https) = rollback.https_proxy {
347                parts.push(format!("https={https}"));
348            }
349            if let Some(ref socks) = rollback.socks_proxy {
350                parts.push(format!("socks={socks}"));
351            }
352            if !parts.is_empty() {
353                let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
354                let proxy_value = parts.join(";");
355                commands.push(Command::new(
356                    "reg",
357                    vec![
358                        "add".into(),
359                        key.into(),
360                        "/v".into(),
361                        "ProxyServer".into(),
362                        "/t".into(),
363                        "REG_SZ".into(),
364                        "/d".into(),
365                        proxy_value,
366                        "/f".into(),
367                    ],
368                ));
369                if rollback.proxy_enabled.unwrap_or(true) {
370                    commands.push(Command::new(
371                        "reg",
372                        vec![
373                            "add".into(),
374                            key.into(),
375                            "/v".into(),
376                            "ProxyEnable".into(),
377                            "/t".into(),
378                            "REG_DWORD".into(),
379                            "/d".into(),
380                            "1".into(),
381                            "/f".into(),
382                        ],
383                    ));
384                }
385            }
386            commands
387        }
388        "linux" => {
389            let mut commands = crate::backends::linux::generate_gnome_disable_commands();
390            let has_any = rollback.http_proxy.is_some()
391                || rollback.https_proxy.is_some()
392                || rollback.socks_proxy.is_some();
393            if has_any {
394                commands.extend(crate::backends::linux::generate_gnome_apply_commands(
395                    rollback.http_proxy.as_deref(),
396                    rollback.https_proxy.as_deref(),
397                    rollback.socks_proxy.as_deref(),
398                    rollback.no_proxy.as_deref(),
399                ));
400            }
401            commands
402        }
403        _ => Vec::new(),
404    }
405}
406
407fn chrono_timestamp() -> String {
408    // Use nanosecond precision to avoid rollback filename collisions within the same second (L-12).
409    let now = std::time::SystemTime::now()
410        .duration_since(std::time::UNIX_EPOCH)
411        .unwrap_or_default()
412        .as_nanos();
413    format!("{now}")
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    #[cfg(unix)]
420    use crate::command_runner::MockCommandRunner;
421    use std::collections::HashMap;
422
423    #[test]
424    fn plan_apply_macos_produces_commands() {
425        let plan = plan_apply(
426            "macos",
427            Some("*Wi-Fi"),
428            Some("proxy:8080"),
429            Some("proxy:8443"),
430            None,
431            None,
432            None,
433        );
434        assert_eq!(plan.platform, "macos");
435        assert!(plan
436            .commands
437            .iter()
438            .any(|c| c.to_string().contains("networksetup")));
439    }
440
441    #[test]
442    fn plan_apply_windows_produces_commands() {
443        let plan = plan_apply("windows", None, Some("proxy:8080"), None, None, None, None);
444        assert_eq!(plan.platform, "windows");
445        assert!(plan
446            .commands
447            .iter()
448            .any(|c| c.to_string().contains("reg add")));
449    }
450
451    #[test]
452    fn plan_apply_linux_produces_commands() {
453        let plan = plan_apply("linux", None, Some("proxy:8080"), None, None, None, None);
454        assert_eq!(plan.platform, "linux");
455        assert!(plan
456            .commands
457            .iter()
458            .any(|c| c.to_string().contains("gsettings")));
459    }
460
461    #[cfg(unix)]
462    #[test]
463    fn execute_apply_preserves_spaces_in_args() {
464        use std::os::unix::process::ExitStatusExt;
465        let runner = MockCommandRunner::new().add_always(
466            "networksetup",
467            Ok(std::process::Output {
468                status: std::process::ExitStatus::from_raw(0),
469                stdout: Vec::new(),
470                stderr: Vec::new(),
471            }),
472        );
473
474        let plan = ApplyPlan {
475            platform: "macos".to_string(),
476            service: Some("USB 10/100/1000 LAN".to_string()),
477            http_proxy: Some("proxy:8080".to_string()),
478            https_proxy: None,
479            socks_proxy: None,
480            no_proxy: None,
481            commands: vec![Command::new(
482                "networksetup",
483                vec![
484                    "-setwebproxy".into(),
485                    "USB 10/100/1000 LAN".into(),
486                    "on".into(),
487                ],
488            )],
489            previous_settings: None,
490        };
491
492        let _ = execute_apply(&plan, &runner).unwrap();
493        let calls = runner.calls();
494        assert_eq!(calls[0].0, "networksetup");
495        assert_eq!(
496            calls[0].1,
497            vec!["-setwebproxy", "USB 10/100/1000 LAN", "on"]
498        );
499    }
500
501    #[test]
502    fn rollback_state_save_and_load() {
503        let state = RollbackState {
504            timestamp: "12345".to_string(),
505            platform: "macos".to_string(),
506            service: Some("*Wi-Fi".to_string()),
507            http_proxy: Some("old-proxy:8080".to_string()),
508            https_proxy: None,
509            socks_proxy: None,
510            no_proxy: None,
511            proxy_enabled: None,
512        };
513
514        let dir = tempfile::tempdir().unwrap();
515        let path = dir.path().join("rollback.json");
516        state.save(&path).unwrap();
517
518        let loaded = RollbackState::load(&path).unwrap();
519        assert_eq!(loaded.platform, "macos");
520        assert_eq!(loaded.http_proxy.as_deref(), Some("old-proxy:8080"));
521    }
522
523    #[cfg(unix)]
524    #[test]
525    fn rollback_state_file_is_owner_only() {
526        use std::os::unix::fs::PermissionsExt;
527
528        let state = RollbackState {
529            timestamp: "12345".to_string(),
530            platform: "linux".to_string(),
531            service: None,
532            http_proxy: Some("http://user:secret@proxy:8080".to_string()),
533            https_proxy: None,
534            socks_proxy: None,
535            no_proxy: None,
536            proxy_enabled: None,
537        };
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("rollback.json");
540
541        state.save(&path).unwrap();
542
543        assert_eq!(
544            std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
545            0o600
546        );
547    }
548
549    #[test]
550    fn create_rollback_from_settings() {
551        let settings = SystemProxySettings {
552            source: "test".to_string(),
553            http_proxy: Some("http://proxy:8080".to_string()),
554            https_proxy: None,
555            socks_proxy: None,
556            no_proxy: None,
557            raw: HashMap::new(),
558        };
559        let rollback = create_rollback("macos", Some("*Wi-Fi"), &settings);
560        assert_eq!(rollback.http_proxy.as_deref(), Some("http://proxy:8080"));
561        assert_eq!(rollback.platform, "macos");
562    }
563
564    #[cfg(unix)]
565    #[test]
566    fn execute_apply_runs_commands() {
567        use std::os::unix::process::ExitStatusExt;
568        let runner = MockCommandRunner::new().add_always(
569            "networksetup",
570            Ok(std::process::Output {
571                status: std::process::ExitStatus::from_raw(0),
572                stdout: Vec::new(),
573                stderr: Vec::new(),
574            }),
575        );
576
577        let plan = ApplyPlan {
578            platform: "macos".to_string(),
579            service: Some("*Wi-Fi".to_string()),
580            http_proxy: Some("proxy:8080".to_string()),
581            https_proxy: None,
582            socks_proxy: None,
583            no_proxy: None,
584            commands: vec![Command::new(
585                "networksetup",
586                vec!["-setwebproxy".into(), "*Wi-Fi".into(), "on".into()],
587            )],
588            previous_settings: None,
589        };
590
591        let executed = execute_apply(&plan, &runner).unwrap();
592        assert_eq!(executed.len(), 1);
593        let calls = runner.calls();
594        assert_eq!(calls[0].0, "networksetup");
595        assert_eq!(calls[0].1, vec!["-setwebproxy", "*Wi-Fi", "on"]);
596    }
597
598    #[test]
599    fn revert_commands_macos() {
600        let rollback = RollbackState {
601            timestamp: "12345".to_string(),
602            platform: "macos".to_string(),
603            service: Some("*Wi-Fi".to_string()),
604            http_proxy: Some("proxy:8080".to_string()),
605            https_proxy: None,
606            socks_proxy: None,
607            no_proxy: None,
608            proxy_enabled: None,
609        };
610        let commands = generate_revert_commands(&rollback);
611        assert!(commands.iter().any(|c| c.to_string().contains("off")));
612        assert!(commands
613            .iter()
614            .any(|c| c.to_string().contains("setwebproxy")));
615    }
616
617    #[cfg(unix)]
618    #[test]
619    fn applied_proxy_restore_is_idempotent() {
620        use std::os::unix::process::ExitStatusExt;
621        let runner = MockCommandRunner::new().add_always(
622            "networksetup",
623            Ok(std::process::Output {
624                status: std::process::ExitStatus::from_raw(0),
625                stdout: Vec::new(),
626                stderr: Vec::new(),
627            }),
628        );
629        let rollback = RollbackState {
630            timestamp: "12345".to_string(),
631            platform: "macos".to_string(),
632            service: Some("*Wi-Fi".to_string()),
633            http_proxy: None,
634            https_proxy: None,
635            socks_proxy: None,
636            no_proxy: None,
637            proxy_enabled: None,
638        };
639        let mut applied = AppliedProxy {
640            rollback: Some(rollback),
641        };
642        applied.restore_with_runner(&runner).unwrap();
643        applied.restore_with_runner(&runner).unwrap();
644        assert!(!runner.calls().is_empty());
645    }
646}