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}
81
82/// In-memory rollback guard for compatibility `--sys`.
83pub struct AppliedProxy {
84    rollback: Option<RollbackState>,
85}
86
87impl AppliedProxy {
88    pub fn restore(&mut self) -> Result<(), String> {
89        let Some(rollback) = self.rollback.take() else {
90            return Ok(());
91        };
92        let runner = crate::command_runner::RealCommandRunner;
93        restore_with_runner(&rollback, &runner)
94    }
95
96    pub fn restore_with_runner(&mut self, runner: &dyn CommandRunner) -> Result<(), String> {
97        let Some(rollback) = self.rollback.take() else {
98            return Ok(());
99        };
100        restore_with_runner(&rollback, runner)
101    }
102}
103
104impl Drop for AppliedProxy {
105    fn drop(&mut self) {
106        let _ = self.restore();
107    }
108}
109
110/// Apply the compatibility system proxy using the platform backend and keep
111/// all rollback state in memory. This is deliberately separate from native
112/// `eggress system-proxy` inspection and from user-authored configuration.
113pub fn apply_compatibility_proxy(
114    kind: CompatibilityProxyKind,
115    address: std::net::SocketAddr,
116) -> Result<AppliedProxy, String> {
117    let runner = crate::command_runner::RealCommandRunner;
118    apply_compatibility_proxy_with_runner(kind, address, &runner)
119}
120
121pub fn apply_compatibility_proxy_with_runner(
122    kind: CompatibilityProxyKind,
123    address: std::net::SocketAddr,
124    runner: &dyn CommandRunner,
125) -> Result<AppliedProxy, String> {
126    let platform = detect_platform();
127    let inspection = inspect_system_proxy_with_runner(runner);
128    if !inspection.apply_supported {
129        return Err(format!(
130            "system proxy apply is unavailable on platform '{platform}'"
131        ));
132    }
133    let settings = inspection
134        .settings
135        .ok_or_else(|| "unable to inspect current system proxy settings".to_string())?;
136    let endpoint = address.to_string();
137    let (http, https, socks) = match kind {
138        CompatibilityProxyKind::Http => (Some(endpoint.clone()), Some(endpoint), None),
139        CompatibilityProxyKind::Socks5 => (None, None, Some(endpoint)),
140    };
141    let plan = plan_apply(
142        &platform,
143        None,
144        http.as_deref(),
145        https.as_deref(),
146        socks.as_deref(),
147        None,
148        Some(&settings),
149    );
150    let rollback = create_rollback(&platform, plan.service.as_deref(), &settings);
151    if let Err(error) = execute_apply(&plan, runner) {
152        let rollback_error = restore_with_runner(&rollback, runner).err();
153        return Err(match rollback_error {
154            Some(rollback_error) => format!("{error}; rollback failed: {rollback_error}"),
155            None => error,
156        });
157    }
158    Ok(AppliedProxy {
159        rollback: Some(rollback),
160    })
161}
162
163fn restore_with_runner(rollback: &RollbackState, runner: &dyn CommandRunner) -> Result<(), String> {
164    for command in generate_revert_commands(rollback) {
165        let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
166        runner
167            .run(&command.program, &args)
168            .map_err(|error| format!("failed to restore '{command}': {error}"))?;
169    }
170    Ok(())
171}
172
173impl RollbackState {
174    /// Save rollback state to a JSON file.
175    pub fn save(&self, path: &PathBuf) -> Result<(), String> {
176        let json = serde_json::to_string_pretty(self)
177            .map_err(|e| format!("failed to serialize rollback state: {e}"))?;
178        std::fs::write(path, json).map_err(|e| format!("failed to write rollback file: {e}"))
179    }
180
181    /// Load rollback state from a JSON file.
182    pub fn load(path: &PathBuf) -> Result<Self, String> {
183        let json = std::fs::read_to_string(path)
184            .map_err(|e| format!("failed to read rollback file: {e}"))?;
185        serde_json::from_str(&json).map_err(|e| format!("failed to parse rollback file: {e}"))
186    }
187}
188
189/// Create an apply plan without executing it (dry-run).
190pub fn plan_apply(
191    platform: &str,
192    service: Option<&str>,
193    http_proxy: Option<&str>,
194    https_proxy: Option<&str>,
195    socks_proxy: Option<&str>,
196    no_proxy: Option<&str>,
197    current_settings: Option<&SystemProxySettings>,
198) -> ApplyPlan {
199    let commands = match platform {
200        "macos" => {
201            let svc = service.unwrap_or("*Wi-Fi");
202            crate::backends::macos::generate_macos_apply_commands(
203                svc,
204                http_proxy,
205                https_proxy,
206                socks_proxy,
207                no_proxy,
208            )
209        }
210        "windows" => crate::backends::windows::generate_windows_apply_commands(
211            http_proxy,
212            https_proxy,
213            socks_proxy,
214            no_proxy,
215        ),
216        "linux" => crate::backends::linux::generate_gnome_apply_commands(
217            http_proxy,
218            https_proxy,
219            socks_proxy,
220            no_proxy,
221        ),
222        _ => Vec::new(),
223    };
224
225    ApplyPlan {
226        platform: platform.to_string(),
227        service: service.map(|s| s.to_string()),
228        http_proxy: http_proxy.map(|s| s.to_string()),
229        https_proxy: https_proxy.map(|s| s.to_string()),
230        socks_proxy: socks_proxy.map(|s| s.to_string()),
231        no_proxy: no_proxy.map(|s| s.to_string()),
232        commands,
233        previous_settings: current_settings.cloned(),
234    }
235}
236
237/// Create a rollback state from current settings.
238pub fn create_rollback(
239    platform: &str,
240    service: Option<&str>,
241    settings: &SystemProxySettings,
242) -> RollbackState {
243    RollbackState {
244        timestamp: chrono_timestamp(),
245        platform: platform.to_string(),
246        service: service.map(|s| s.to_string()),
247        http_proxy: settings.http_proxy.clone(),
248        https_proxy: settings.https_proxy.clone(),
249        socks_proxy: settings.socks_proxy.clone(),
250        no_proxy: settings.no_proxy.clone(),
251    }
252}
253
254/// Execute an apply plan using the provided command runner.
255pub fn execute_apply(plan: &ApplyPlan, runner: &dyn CommandRunner) -> Result<Vec<Command>, String> {
256    let mut executed = Vec::new();
257    for cmd in &plan.commands {
258        let arg_refs: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
259        runner
260            .run(&cmd.program, &arg_refs)
261            .map_err(|e| format!("failed to execute '{cmd}': {e}"))?;
262        executed.push(cmd.clone());
263    }
264    Ok(executed)
265}
266
267/// Generate revert commands from rollback state.
268pub fn generate_revert_commands(rollback: &RollbackState) -> Vec<Command> {
269    match rollback.platform.as_str() {
270        "macos" => {
271            let svc = rollback.service.as_deref().unwrap_or("*Wi-Fi");
272            let mut commands = crate::backends::macos::generate_macos_disable_commands(svc);
273            if let Some(ref http) = rollback.http_proxy {
274                if !http.is_empty() {
275                    commands.push(Command::new(
276                        "networksetup",
277                        vec!["-setwebproxy".into(), svc.into(), "on".into()],
278                    ));
279                    commands.push(Command::new(
280                        "networksetup",
281                        vec!["-setwebproxyservers".into(), svc.into(), http.clone()],
282                    ));
283                }
284            }
285            if let Some(ref https) = rollback.https_proxy {
286                if !https.is_empty() {
287                    commands.push(Command::new(
288                        "networksetup",
289                        vec!["-setsecurewebproxy".into(), svc.into(), "on".into()],
290                    ));
291                    commands.push(Command::new(
292                        "networksetup",
293                        vec![
294                            "-setsecurewebproxyservers".into(),
295                            svc.into(),
296                            https.clone(),
297                        ],
298                    ));
299                }
300            }
301            if let Some(ref socks) = rollback.socks_proxy {
302                if !socks.is_empty() {
303                    commands.push(Command::new(
304                        "networksetup",
305                        vec!["-setsocksfirewallproxy".into(), svc.into(), "on".into()],
306                    ));
307                    commands.push(Command::new(
308                        "networksetup",
309                        vec![
310                            "-setsocksfirewallproxyserver".into(),
311                            svc.into(),
312                            socks.clone(),
313                        ],
314                    ));
315                }
316            }
317            commands
318        }
319        "windows" => {
320            let mut commands = crate::backends::windows::generate_windows_disable_commands();
321            let mut parts = Vec::new();
322            if let Some(ref http) = rollback.http_proxy {
323                parts.push(format!("http={http}"));
324            }
325            if let Some(ref https) = rollback.https_proxy {
326                parts.push(format!("https={https}"));
327            }
328            if let Some(ref socks) = rollback.socks_proxy {
329                parts.push(format!("socks={socks}"));
330            }
331            if !parts.is_empty() {
332                let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
333                let proxy_value = parts.join(";");
334                commands.push(Command::new(
335                    "reg",
336                    vec![
337                        "add".into(),
338                        key.into(),
339                        "/v".into(),
340                        "ProxyServer".into(),
341                        "/t".into(),
342                        "REG_SZ".into(),
343                        "/d".into(),
344                        proxy_value,
345                        "/f".into(),
346                    ],
347                ));
348                commands.push(Command::new(
349                    "reg",
350                    vec![
351                        "add".into(),
352                        key.into(),
353                        "/v".into(),
354                        "ProxyEnable".into(),
355                        "/t".into(),
356                        "REG_DWORD".into(),
357                        "/d".into(),
358                        "1".into(),
359                        "/f".into(),
360                    ],
361                ));
362            }
363            commands
364        }
365        "linux" => {
366            let mut commands = crate::backends::linux::generate_gnome_disable_commands();
367            let has_any = rollback.http_proxy.is_some()
368                || rollback.https_proxy.is_some()
369                || rollback.socks_proxy.is_some();
370            if has_any {
371                commands.extend(crate::backends::linux::generate_gnome_apply_commands(
372                    rollback.http_proxy.as_deref(),
373                    rollback.https_proxy.as_deref(),
374                    rollback.socks_proxy.as_deref(),
375                    rollback.no_proxy.as_deref(),
376                ));
377            }
378            commands
379        }
380        _ => Vec::new(),
381    }
382}
383
384fn chrono_timestamp() -> String {
385    // Use simple timestamp without chrono dependency
386    let now = std::time::SystemTime::now()
387        .duration_since(std::time::UNIX_EPOCH)
388        .unwrap_or_default()
389        .as_secs();
390    format!("{now}")
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    #[cfg(unix)]
397    use crate::command_runner::MockCommandRunner;
398    use std::collections::HashMap;
399
400    #[test]
401    fn plan_apply_macos_produces_commands() {
402        let plan = plan_apply(
403            "macos",
404            Some("*Wi-Fi"),
405            Some("proxy:8080"),
406            Some("proxy:8443"),
407            None,
408            None,
409            None,
410        );
411        assert_eq!(plan.platform, "macos");
412        assert!(plan
413            .commands
414            .iter()
415            .any(|c| c.to_string().contains("networksetup")));
416    }
417
418    #[test]
419    fn plan_apply_windows_produces_commands() {
420        let plan = plan_apply("windows", None, Some("proxy:8080"), None, None, None, None);
421        assert_eq!(plan.platform, "windows");
422        assert!(plan
423            .commands
424            .iter()
425            .any(|c| c.to_string().contains("reg add")));
426    }
427
428    #[test]
429    fn plan_apply_linux_produces_commands() {
430        let plan = plan_apply("linux", None, Some("proxy:8080"), None, None, None, None);
431        assert_eq!(plan.platform, "linux");
432        assert!(plan
433            .commands
434            .iter()
435            .any(|c| c.to_string().contains("gsettings")));
436    }
437
438    #[cfg(unix)]
439    #[test]
440    fn execute_apply_preserves_spaces_in_args() {
441        use std::os::unix::process::ExitStatusExt;
442        let runner = MockCommandRunner::new().add_always(
443            "networksetup",
444            Ok(std::process::Output {
445                status: std::process::ExitStatus::from_raw(0),
446                stdout: Vec::new(),
447                stderr: Vec::new(),
448            }),
449        );
450
451        let plan = ApplyPlan {
452            platform: "macos".to_string(),
453            service: Some("USB 10/100/1000 LAN".to_string()),
454            http_proxy: Some("proxy:8080".to_string()),
455            https_proxy: None,
456            socks_proxy: None,
457            no_proxy: None,
458            commands: vec![Command::new(
459                "networksetup",
460                vec![
461                    "-setwebproxy".into(),
462                    "USB 10/100/1000 LAN".into(),
463                    "on".into(),
464                ],
465            )],
466            previous_settings: None,
467        };
468
469        let _ = execute_apply(&plan, &runner).unwrap();
470        let calls = runner.calls();
471        assert_eq!(calls[0].0, "networksetup");
472        assert_eq!(
473            calls[0].1,
474            vec!["-setwebproxy", "USB 10/100/1000 LAN", "on"]
475        );
476    }
477
478    #[test]
479    fn rollback_state_save_and_load() {
480        let state = RollbackState {
481            timestamp: "12345".to_string(),
482            platform: "macos".to_string(),
483            service: Some("*Wi-Fi".to_string()),
484            http_proxy: Some("old-proxy:8080".to_string()),
485            https_proxy: None,
486            socks_proxy: None,
487            no_proxy: None,
488        };
489
490        let dir = tempfile::tempdir().unwrap();
491        let path = dir.path().join("rollback.json");
492        state.save(&path).unwrap();
493
494        let loaded = RollbackState::load(&path).unwrap();
495        assert_eq!(loaded.platform, "macos");
496        assert_eq!(loaded.http_proxy.as_deref(), Some("old-proxy:8080"));
497    }
498
499    #[test]
500    fn create_rollback_from_settings() {
501        let settings = SystemProxySettings {
502            source: "test".to_string(),
503            http_proxy: Some("http://proxy:8080".to_string()),
504            https_proxy: None,
505            socks_proxy: None,
506            no_proxy: None,
507            raw: HashMap::new(),
508        };
509        let rollback = create_rollback("macos", Some("*Wi-Fi"), &settings);
510        assert_eq!(rollback.http_proxy.as_deref(), Some("http://proxy:8080"));
511        assert_eq!(rollback.platform, "macos");
512    }
513
514    #[cfg(unix)]
515    #[test]
516    fn execute_apply_runs_commands() {
517        use std::os::unix::process::ExitStatusExt;
518        let runner = MockCommandRunner::new().add_always(
519            "networksetup",
520            Ok(std::process::Output {
521                status: std::process::ExitStatus::from_raw(0),
522                stdout: Vec::new(),
523                stderr: Vec::new(),
524            }),
525        );
526
527        let plan = ApplyPlan {
528            platform: "macos".to_string(),
529            service: Some("*Wi-Fi".to_string()),
530            http_proxy: Some("proxy:8080".to_string()),
531            https_proxy: None,
532            socks_proxy: None,
533            no_proxy: None,
534            commands: vec![Command::new(
535                "networksetup",
536                vec!["-setwebproxy".into(), "*Wi-Fi".into(), "on".into()],
537            )],
538            previous_settings: None,
539        };
540
541        let executed = execute_apply(&plan, &runner).unwrap();
542        assert_eq!(executed.len(), 1);
543        let calls = runner.calls();
544        assert_eq!(calls[0].0, "networksetup");
545        assert_eq!(calls[0].1, vec!["-setwebproxy", "*Wi-Fi", "on"]);
546    }
547
548    #[test]
549    fn revert_commands_macos() {
550        let rollback = RollbackState {
551            timestamp: "12345".to_string(),
552            platform: "macos".to_string(),
553            service: Some("*Wi-Fi".to_string()),
554            http_proxy: Some("proxy:8080".to_string()),
555            https_proxy: None,
556            socks_proxy: None,
557            no_proxy: None,
558        };
559        let commands = generate_revert_commands(&rollback);
560        assert!(commands.iter().any(|c| c.to_string().contains("off")));
561        assert!(commands
562            .iter()
563            .any(|c| c.to_string().contains("setwebproxy")));
564    }
565
566    #[cfg(unix)]
567    #[test]
568    fn applied_proxy_restore_is_idempotent() {
569        use std::os::unix::process::ExitStatusExt;
570        let runner = MockCommandRunner::new().add_always(
571            "networksetup",
572            Ok(std::process::Output {
573                status: std::process::ExitStatus::from_raw(0),
574                stdout: Vec::new(),
575                stderr: Vec::new(),
576            }),
577        );
578        let rollback = RollbackState {
579            timestamp: "12345".to_string(),
580            platform: "macos".to_string(),
581            service: Some("*Wi-Fi".to_string()),
582            http_proxy: None,
583            https_proxy: None,
584            socks_proxy: None,
585            no_proxy: None,
586        };
587        let mut applied = AppliedProxy {
588            rollback: Some(rollback),
589        };
590        applied.restore_with_runner(&runner).unwrap();
591        applied.restore_with_runner(&runner).unwrap();
592        assert!(!runner.calls().is_empty());
593    }
594}