Skip to main content

kftray_portforward/
network_utils.rs

1use std::net::IpAddr;
2#[cfg(any(target_os = "macos", target_os = "linux"))]
3use std::process::Command;
4use std::str::FromStr;
5
6use anyhow::{
7    Result,
8    anyhow,
9};
10use tracing::{
11    debug,
12    error,
13    info,
14    warn,
15};
16
17#[cfg(any(target_os = "macos", target_os = "linux"))]
18pub fn execute_command(cmd: &str, args: &[&str]) -> anyhow::Result<()> {
19    let status = Command::new(cmd)
20        .args(args)
21        .status()
22        .map_err(|e| anyhow!("failed to spawn `{}`: {}", cmd, e))?;
23    if status.success() {
24        Ok(())
25    } else {
26        Err(anyhow!("`{}` exited with {}", cmd, status))
27    }
28}
29
30pub fn is_loopback_address(addr: &str) -> bool {
31    if let Ok(ip) = IpAddr::from_str(addr) {
32        return ip.is_loopback();
33    }
34    false
35}
36
37pub fn is_default_loopback_address(addr: &str) -> bool {
38    addr == "127.0.0.1"
39}
40
41pub fn is_custom_loopback_address(addr: &str) -> bool {
42    is_loopback_address(addr) && !is_default_loopback_address(addr)
43}
44
45pub async fn ensure_loopback_address(addr: &str) -> Result<()> {
46    if !is_loopback_address(addr) {
47        return Ok(());
48    }
49
50    if addr == "127.0.0.1" {
51        return Ok(());
52    }
53
54    debug!("Ensuring loopback address {} is configured", addr);
55
56    if is_address_accessible(addr).await {
57        debug!("Loopback address {} is already accessible", addr);
58        return Ok(());
59    }
60
61    info!("Configuring loopback address: {}", addr);
62
63    let helper_result = configure_loopback_with_helper(addr).await;
64
65    if helper_result.is_ok() {
66        debug!(
67            "Successfully configured loopback address via helper: {}",
68            addr
69        );
70
71        if is_address_accessible(addr).await {
72            debug!("Verified loopback address {} is now accessible", addr);
73            return Ok(());
74        } else {
75            warn!(
76                "Helper claimed success but address {} is still not accessible",
77                addr
78            );
79        }
80    } else if let Err(e) = helper_result {
81        warn!("Failed to configure loopback address with helper: {}", e);
82    }
83
84    debug!("Falling back to traditional methods for configuring loopback address");
85
86    #[cfg(target_os = "macos")]
87    {
88        info!("Using macOS-specific method for loopback configuration");
89        configure_loopback_macos(addr)?;
90    }
91
92    #[cfg(target_os = "linux")]
93    {
94        info!("Using Linux-specific method for loopback configuration");
95        configure_loopback_linux(addr)?;
96    }
97
98    #[cfg(target_os = "windows")]
99    {
100        info!("Using Windows-specific method for loopback configuration");
101        configure_loopback_windows(addr)?;
102    }
103
104    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
105    {
106        return Err(anyhow!(
107            "Loopback address configuration not supported on this platform"
108        ));
109    }
110
111    if is_address_accessible(addr).await {
112        debug!("Successfully configured loopback address: {}", addr);
113        Ok(())
114    } else {
115        Err(anyhow!(
116            "Failed to configure loopback address {} after all attempts",
117            addr
118        ))
119    }
120}
121
122async fn configure_loopback_with_helper(addr: &str) -> Result<()> {
123    debug!("Checking if helper is available");
124
125    let app_id = "com.kftray.app".to_string();
126
127    let socket_path = kftray_helper::communication::get_default_socket_path()?;
128
129    if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
130        return Err(anyhow!("Helper service is not available"));
131    }
132
133    debug!("Helper service is available, proceeding with configuration");
134
135    let command = kftray_helper::messages::RequestCommand::Network(
136        kftray_helper::messages::NetworkCommand::Add {
137            address: addr.to_string(),
138        },
139    );
140
141    match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
142        Ok(response) => match response.result {
143            kftray_helper::messages::RequestResult::Success => {
144                debug!(
145                    "Successfully configured loopback address with helper: {}",
146                    addr
147                );
148                Ok(())
149            }
150            kftray_helper::messages::RequestResult::Error(e) => {
151                error!("Helper failed to add loopback address: {}", e);
152                Err(anyhow!(
153                    "Helper failed to configure loopback address: {}",
154                    e
155                ))
156            }
157            other => Err(anyhow!(
158                "Unexpected helper response to loopback add: {:?}",
159                other
160            )),
161        },
162        Err(e) => {
163            error!("Helper failed to add loopback address: {}", e);
164            Err(anyhow!(
165                "Helper failed to configure loopback address: {}",
166                e
167            ))
168        }
169    }
170}
171
172async fn remove_loopback_with_helper(addr: &str) -> Result<()> {
173    debug!("Checking if helper is available for address removal");
174
175    let app_id = "com.kftray.app".to_string();
176
177    let socket_path = kftray_helper::communication::get_default_socket_path()?;
178
179    if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
180        return Err(anyhow!("Helper service is not available"));
181    }
182
183    debug!("Helper service is available, proceeding with address removal");
184
185    let command = kftray_helper::messages::RequestCommand::Network(
186        kftray_helper::messages::NetworkCommand::Remove {
187            address: addr.to_string(),
188        },
189    );
190
191    match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
192        Ok(response) => match response.result {
193            kftray_helper::messages::RequestResult::Success => {
194                debug!(
195                    "Successfully removed loopback address with helper: {}",
196                    addr
197                );
198                Ok(())
199            }
200            kftray_helper::messages::RequestResult::Error(e) => {
201                error!("Helper failed to remove loopback address: {}", e);
202                Err(anyhow!("Helper failed to remove loopback address: {}", e))
203            }
204            other => Err(anyhow!(
205                "Unexpected helper response to loopback removal: {:?}",
206                other
207            )),
208        },
209        Err(e) => {
210            error!("Helper failed to remove loopback address: {}", e);
211            Err(anyhow!("Helper failed to remove loopback address: {}", e))
212        }
213    }
214}
215
216/// Whether an error from `remove_loopback_with_helper` means the helper
217/// itself is unreachable (not installed, not running, or its socket gone)
218/// rather than the removal request itself failing.
219///
220/// Only unreachability is unsatisfiable without a privilege change: any
221/// other failure (a serialization error, a broken pipe mid-request, a
222/// malformed response) is a transient IPC problem that a retry can still
223/// resolve once the alias is confirmed present.
224#[cfg(any(target_os = "macos", all(test, unix)))]
225fn helper_is_unavailable(error: &str) -> bool {
226    error.contains("Helper service is not available")
227        || error.contains("Connection refused")
228        || error.contains("No such file or directory")
229}
230
231/// Outcome of attempting to release a loopback alias.
232///
233/// A caller cannot treat every non-removal the same way: one where the
234/// helper is missing or `sudo` needs a password will not resolve itself by
235/// retrying, so it must be surfaced once and recorded as unsatisfiable here,
236/// rather than kept as a cleanup that is retried forever.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum LoopbackRelease {
239    /// The alias was configured and has been removed.
240    Released,
241    /// Nothing was configured for this address; there was nothing to remove.
242    AlreadyAbsent,
243    /// The alias is still configured, but this process cannot escalate the
244    /// privileges removal needs right now (helper unavailable, `sudo -n`
245    /// needs a password). Retrying without a privilege change will not help.
246    PrivilegeUnavailable(String),
247    /// The alias is still configured and removal failed for a reason a later
248    /// attempt might resolve.
249    Failed(String),
250}
251
252/// Remove loopback address. Only uses helper service - skips osascript fallback
253/// to avoid blocking on user interaction during stop operations.
254pub async fn remove_loopback_address(addr: &str) -> Result<LoopbackRelease> {
255    if !is_loopback_address(addr) {
256        return Ok(LoopbackRelease::AlreadyAbsent);
257    }
258
259    if is_default_loopback_address(addr) || addr == "::1" {
260        return Ok(LoopbackRelease::AlreadyAbsent);
261    }
262    #[cfg(target_os = "windows")]
263    {
264        // kftray never actually configures a Windows interface alias for a
265        // loopback address (`configure_loopback_windows` is a no-op), and
266        // the whole 127.0.0.0/8 range is bindable without one, so there is
267        // nothing for the helper to remove and no reason to call it.
268        if let Ok(IpAddr::V4(v4)) = addr.parse::<IpAddr>()
269            && v4.octets()[0] == 127
270        {
271            debug!(
272                "No interface alias is ever configured for {addr} on Windows; nothing to remove"
273            );
274
275            return Ok(LoopbackRelease::AlreadyAbsent);
276        }
277    }
278
279    debug!("Removing loopback address: {}", addr);
280
281    // Try helper service first (non-blocking)
282    let helper_result = remove_loopback_with_helper(addr).await;
283
284    if helper_result.is_ok() {
285        debug!("Successfully removed loopback address via helper: {}", addr);
286        return Ok(LoopbackRelease::Released);
287    } else if let Err(e) = &helper_result {
288        warn!("Failed to remove loopback address with helper: {}", e);
289    }
290
291    // Skip osascript fallback on macOS - it blocks waiting for user interaction
292    // which can hang stop operations indefinitely.
293    #[cfg(target_os = "macos")]
294    {
295        // An alias that is not there needs no removal. Without this check a
296        // machine with no helper reports every stop as leaving an alias
297        // behind, and the cleanup record that failure keeps would block
298        // deleting the configuration for the rest of the session.
299        //
300        // Only a successful query proves absence: a failed one must stay an
301        // error, or a broken `ifconfig` would read as confirmed cleanup.
302        if !macos_alias_exists(addr)? {
303            debug!("No loopback alias for {addr}; nothing to remove");
304
305            return Ok(LoopbackRelease::AlreadyAbsent);
306        }
307
308        // The helper is the only non-blocking removal path on macOS, so its
309        // unreachability is a privilege problem: nothing this process
310        // retries on its own will make it reachable. Any other helper
311        // failure (a timed-out request, a malformed response) is not a
312        // privilege problem and is worth retrying once the alias is
313        // confirmed present.
314        let message = helper_result.unwrap_err().to_string();
315        if helper_is_unavailable(&message) {
316            warn!(
317                "Could not remove loopback address {} via helper, and the osascript fallback would block.",
318                addr
319            );
320
321            return Ok(LoopbackRelease::PrivilegeUnavailable(format!(
322                "Loopback address {addr} is still configured: removing it needs the helper"
323            )));
324        }
325
326        warn!("Helper failed to remove loopback address {addr}: {message}");
327
328        Ok(LoopbackRelease::Failed(format!(
329            "Loopback address {addr} is still configured: {message}"
330        )))
331    }
332
333    #[cfg(target_os = "linux")]
334    {
335        info!("Using Linux-specific method for loopback removal");
336        // Linux routes the whole 127.0.0.0/8 range to the loopback interface,
337        // so an address can be bound without ever adding an explicit alias.
338        // Deleting one that was never added is not a cleanup failure.
339        // Only a successful query proves the alias is absent. A failed one
340        // (for example `ip` missing from the process PATH) must not be read as
341        // confirmed cleanup.
342        if !linux_alias_exists(addr)? {
343            debug!("No explicit loopback alias for {addr}; nothing to remove");
344
345            return Ok(LoopbackRelease::AlreadyAbsent);
346        }
347        if unsafe { libc::geteuid() } == 0 {
348            match execute_command("ip", &["addr", "del", addr, "dev", "lo"]) {
349                Ok(()) => Ok(LoopbackRelease::Released),
350                Err(e) => Ok(resolve_failed_removal(e.to_string(), || {
351                    linux_alias_exists(addr)
352                })),
353            }
354        } else {
355            // `-n` keeps this non-interactive: a password prompt during a stop
356            // would block until someone typed into a terminal that may not even
357            // be attached. `sudo -n` failing outright, rather than prompting,
358            // means this process cannot escalate without one, so it is a
359            // privilege problem rather than a failure a retry can fix.
360            Ok(classify_sudo_ip_addr_del(
361                addr,
362                run_sudo_ip_addr_del(addr),
363                || linux_alias_exists(addr),
364            ))
365        }
366    }
367
368    #[cfg(target_os = "windows")]
369    {
370        info!("Using Windows-specific method for loopback removal");
371        // Only a non-127.0.0.0/8 address (the IPv6 loopback) reaches here:
372        // 127.0.0.0/8 addresses already returned above without calling the
373        // helper. Unlike that range, this address is not guaranteed
374        // reachable without an alias, but a helper failure is still not
375        // proof that new privileges are needed, so it is worth retrying.
376        Ok(LoopbackRelease::Failed(format!(
377            "Loopback address {addr} is still configured: removing it needs the helper"
378        )))
379    }
380
381    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
382    {
383        Ok(LoopbackRelease::Failed(
384            "Loopback address removal not supported on this platform".to_string(),
385        ))
386    }
387}
388
389pub async fn is_address_accessible(addr: &str) -> bool {
390    let socket_addr = format!("{addr}:0");
391    tokio::net::TcpListener::bind(socket_addr).await.is_ok()
392}
393
394#[cfg(target_os = "macos")]
395fn configure_loopback_macos(addr: &str) -> Result<()> {
396    let check_output = Command::new("ifconfig")
397        .args(["lo0"])
398        .output()
399        .map_err(|e| anyhow!("Failed to check loopback interface: {}", e))?;
400
401    let output_str = String::from_utf8_lossy(&check_output.stdout);
402    if output_str.contains(addr) {
403        debug!("Loopback address {} is already configured on lo0", addr);
404        return Ok(());
405    }
406
407    debug!("Trying to add loopback address alias with osascript");
408    let script =
409        format!(r#"do shell script "ifconfig lo0 alias {addr}" with administrator privileges"#);
410
411    let result = Command::new("osascript").args(["-e", &script]).output();
412
413    match result {
414        Ok(output) if output.status.success() => {
415            debug!("Successfully added loopback address using osascript with admin privileges");
416            Ok(())
417        }
418        Ok(output) => {
419            let stderr = String::from_utf8_lossy(&output.stderr);
420            debug!(
421                "osascript command failed with exit code {}: {}",
422                output.status.code().unwrap_or(-1),
423                stderr
424            );
425
426            if stderr.contains("User cancelled")
427                || stderr.contains("user cancelled")
428                || stderr.contains("cancelled")
429                || stderr.contains("User canceled")
430                || stderr.contains("canceled")
431                || stderr.contains("(-128)")
432                || output.status.code() == Some(1)
433            {
434                Err(anyhow!("User cancelled loopback address configuration"))
435            } else {
436                Err(anyhow!("Failed to configure loopback address: {}", stderr))
437            }
438        }
439        Err(e) => Err(anyhow!("Failed to execute osascript: {}", e)),
440    }
441}
442
443/// Whether `addr` is configured on the loopback interface.
444///
445/// Matched field by field rather than by substring: `127.0.0.1` is a substring
446/// of `127.0.0.10`, and treating one as the other would either skip a removal
447/// that is owed or report one that is not.
448#[cfg(target_os = "macos")]
449fn macos_alias_exists(addr: &str) -> Result<bool> {
450    let output = Command::new("/sbin/ifconfig")
451        .arg("lo0")
452        .output()
453        .map_err(|error| anyhow!("Failed to query loopback aliases: {error}"))?;
454    if !output.status.success() {
455        return Err(anyhow!(
456            "Failed to query loopback aliases: {}",
457            String::from_utf8_lossy(&output.stderr).trim()
458        ));
459    }
460
461    Ok(ifconfig_lists_address(
462        &String::from_utf8_lossy(&output.stdout),
463        addr,
464    ))
465}
466
467/// Whether `ifconfig` output lists `addr` as a configured address.
468#[cfg(target_os = "macos")]
469fn ifconfig_lists_address(output: &str, addr: &str) -> bool {
470    output
471        .lines()
472        .filter_map(|line| {
473            let mut fields = line.split_whitespace();
474            matches!(fields.next(), Some("inet" | "inet6")).then(|| fields.next())?
475        })
476        .any(|configured| configured == addr)
477}
478
479#[cfg(target_os = "linux")]
480fn linux_alias_exists(addr: &str) -> Result<bool> {
481    let output = Command::new("ip")
482        .args(["-o", "addr", "show", "dev", "lo"])
483        .output()
484        .map_err(|error| anyhow!("Failed to query loopback aliases: {error}"))?;
485    if !output.status.success() {
486        return Err(anyhow!(
487            "Failed to query loopback aliases: {}",
488            String::from_utf8_lossy(&output.stderr).trim()
489        ));
490    }
491
492    Ok(String::from_utf8_lossy(&output.stdout)
493        .split_whitespace()
494        .any(|field| field == addr || field.starts_with(&format!("{addr}/"))))
495}
496
497/// Whether a failed `sudo -n <cmd>` exited because sudo itself refused the
498/// credentials (no cached ticket and no way to prompt for a password)
499/// rather than the wrapped command failing on its own after sudo let it
500/// run.
501///
502/// Kept independent of any process spawning so the classification itself is
503/// exercised directly: `sudo -n` reports a refusal as exit code 1 with a
504/// message on stderr, either the exact "a password is required" text or one
505/// beginning with sudo's own "sudo:" prefix (missing tty, unknown user,
506/// etc). Any other failure is `ip` (or another wrapped command) failing on
507/// its own, which a retry might resolve without new privileges.
508#[cfg(any(target_os = "linux", all(test, unix)))]
509fn is_sudo_privilege_refusal(exit_code: Option<i32>, stderr: &str) -> bool {
510    exit_code == Some(1) && (stderr.contains("a password is required") || stderr.contains("sudo:"))
511}
512
513#[cfg(target_os = "linux")]
514fn run_sudo_ip_addr_del(addr: &str) -> std::io::Result<std::process::Output> {
515    Command::new("sudo")
516        .args(["-n", "ip", "addr", "del", addr, "dev", "lo"])
517        .output()
518}
519
520/// Classifies the outcome of a non-root `sudo -n ip addr del`.
521///
522/// Every non-root failure used to map to [`LoopbackRelease::PrivilegeUnavailable`],
523/// which also swallowed `ip addr del` itself failing (missing binary, device
524/// busy, and the like) or a `sudo`/`ip` exec failure other than a real
525/// credentials refusal. Only a genuine sudo refusal is unsatisfiable without
526/// new privileges; anything else is worth retrying, so it is reported as
527/// [`LoopbackRelease::Failed`] once `alias_present` confirms the alias is
528/// still there.
529#[cfg(any(target_os = "linux", all(test, unix)))]
530fn classify_sudo_ip_addr_del(
531    addr: &str, spawned: std::io::Result<std::process::Output>,
532    alias_present: impl FnOnce() -> Result<bool>,
533) -> LoopbackRelease {
534    let output = match spawned {
535        Ok(output) => output,
536        Err(error) => {
537            return LoopbackRelease::PrivilegeUnavailable(format!(
538                "Removing loopback address {addr} needs a privileged `ip addr del`, and `sudo` \
539                 could not be executed: {error}"
540            ));
541        }
542    };
543    if output.status.success() {
544        return LoopbackRelease::Released;
545    }
546    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
547    if is_sudo_privilege_refusal(output.status.code(), &stderr) {
548        return LoopbackRelease::PrivilegeUnavailable(format!(
549            "Removing loopback address {addr} needs a privileged `ip addr del`, and `sudo -n` \
550             refused it (a password may be required): {stderr}"
551        ));
552    }
553    resolve_failed_removal(
554        format!("`sudo -n ip addr del` failed: {stderr}"),
555        alias_present,
556    )
557}
558
559/// Re-queries `alias_present` after a failed removal attempt rather than
560/// assuming the alias is still there: the alias can already be gone despite
561/// the reported failure, and recording `Failed` for that would keep a
562/// cleanup that already succeeded stuck retrying forever.
563#[cfg(any(target_os = "linux", all(test, unix)))]
564fn resolve_failed_removal(
565    error_message: String, alias_present: impl FnOnce() -> Result<bool>,
566) -> LoopbackRelease {
567    match alias_present() {
568        Ok(false) => LoopbackRelease::Released,
569        _ => LoopbackRelease::Failed(error_message),
570    }
571}
572
573#[cfg(target_os = "linux")]
574fn configure_loopback_linux(addr: &str) -> Result<()> {
575    if unsafe { libc::geteuid() } == 0 {
576        execute_command("ip", &["addr", "add", addr, "dev", "lo"])?;
577
578        execute_command(
579            "ip",
580            &["route", "add", &format!("{}/32", addr), "dev", "lo"],
581        )
582        .or_else(|e| {
583            debug!("Route might already exist: {}", e);
584            Ok(())
585        })
586    } else {
587        execute_command("/usr/bin/pkexec", &["ip", "addr", "add", addr, "dev", "lo"])
588            .or_else(|_| execute_command("sudo", &["ip", "addr", "add", addr, "dev", "lo"]))?;
589
590        execute_command(
591            "/usr/bin/pkexec",
592            &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
593        )
594        .or_else(|_| {
595            execute_command(
596                "sudo",
597                &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
598            )
599        })
600        .or_else(|e| {
601            debug!("Route might already exist: {}", e);
602            Ok(())
603        })
604    }
605}
606
607#[cfg(target_os = "windows")]
608fn configure_loopback_windows(_addr: &str) -> Result<()> {
609    Ok(())
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn test_is_loopback_address() {
618        assert!(is_loopback_address("127.0.0.1"));
619        assert!(is_loopback_address("127.0.0.2"));
620        assert!(is_loopback_address("127.255.255.255"));
621        assert!(!is_loopback_address("192.168.1.1"));
622        assert!(!is_loopback_address("10.0.0.1"));
623        assert!(!is_loopback_address("invalid-ip"));
624    }
625
626    #[cfg(target_os = "macos")]
627    #[test]
628    fn a_configured_alias_is_matched_whole() {
629        let output = "lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n\
630                      \toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>\n\
631                      \tinet 127.0.0.1 netmask 0xff000000\n\
632                      \tinet6 ::1 prefixlen 128\n\
633                      \tinet 127.0.0.10 netmask 0xff000000\n";
634
635        assert!(ifconfig_lists_address(output, "127.0.0.1"));
636        assert!(ifconfig_lists_address(output, "127.0.0.10"));
637        assert!(ifconfig_lists_address(output, "::1"));
638        // A prefix of a configured address is not configured itself, and
639        // treating it as present would keep reporting a removal that is owed.
640        assert!(!ifconfig_lists_address(output, "127.0.0.2"));
641        assert!(!ifconfig_lists_address(output, "127.0.0."));
642        // Only the address field counts, never the flags or netmask columns.
643        assert!(!ifconfig_lists_address(output, "0xff000000"));
644    }
645
646    #[test]
647    fn test_is_default_loopback_address() {
648        assert!(is_default_loopback_address("127.0.0.1"));
649        assert!(!is_default_loopback_address("127.0.0.2"));
650        assert!(!is_default_loopback_address("127.255.255.255"));
651        assert!(!is_default_loopback_address("192.168.1.1"));
652        assert!(!is_default_loopback_address("10.0.0.1"));
653        assert!(!is_default_loopback_address("invalid-ip"));
654    }
655
656    #[test]
657    fn test_is_custom_loopback_address() {
658        assert!(!is_custom_loopback_address("127.0.0.1"));
659        assert!(is_custom_loopback_address("127.0.0.2"));
660        assert!(is_custom_loopback_address("127.0.0.5"));
661        assert!(is_custom_loopback_address("127.255.255.255"));
662        assert!(!is_custom_loopback_address("192.168.1.1"));
663        assert!(!is_custom_loopback_address("10.0.0.1"));
664        assert!(!is_custom_loopback_address("invalid-ip"));
665    }
666
667    #[tokio::test]
668    async fn test_ensure_loopback_address_non_loopback() {
669        assert!(ensure_loopback_address("192.168.1.1").await.is_ok());
670    }
671
672    #[tokio::test]
673    async fn test_ensure_loopback_address_default() {
674        assert!(ensure_loopback_address("127.0.0.1").await.is_ok());
675    }
676
677    #[tokio::test]
678    async fn test_remove_loopback_address_non_loopback_is_already_absent() {
679        assert_eq!(
680            remove_loopback_address("192.168.1.1").await.unwrap(),
681            LoopbackRelease::AlreadyAbsent
682        );
683    }
684
685    #[tokio::test]
686    async fn test_remove_loopback_address_default_is_already_absent() {
687        assert_eq!(
688            remove_loopback_address("127.0.0.1").await.unwrap(),
689            LoopbackRelease::AlreadyAbsent
690        );
691    }
692
693    #[tokio::test]
694    async fn test_remove_loopback_address_ipv6_default_is_already_absent() {
695        assert_eq!(
696            remove_loopback_address("::1").await.unwrap(),
697            LoopbackRelease::AlreadyAbsent
698        );
699    }
700
701    #[cfg(unix)]
702    #[test]
703    fn a_non_interactive_sudo_password_refusal_is_a_privilege_refusal() {
704        assert!(is_sudo_privilege_refusal(
705            Some(1),
706            "sudo: a password is required"
707        ));
708    }
709
710    #[cfg(unix)]
711    #[test]
712    fn any_sudo_prefixed_stderr_at_exit_one_is_a_privilege_refusal() {
713        // Covers sudo's own diagnostics beyond the password message: no tty,
714        // unknown user, policy denial, and the like all start with "sudo:".
715        assert!(is_sudo_privilege_refusal(
716            Some(1),
717            "sudo: no tty present and no askpass program specified"
718        ));
719    }
720
721    #[cfg(unix)]
722    #[test]
723    fn ip_failing_on_its_own_is_not_a_privilege_refusal() {
724        // `ip` ran (sudo let it through) and failed for a reason a retry
725        // might resolve, not because sudo withheld credentials.
726        assert!(!is_sudo_privilege_refusal(
727            Some(1),
728            "Cannot find device \"lo\""
729        ));
730        assert!(!is_sudo_privilege_refusal(Some(2), ""));
731    }
732
733    #[cfg(unix)]
734    fn exited(code: i32, stderr: &[u8]) -> std::process::Output {
735        use std::os::unix::process::ExitStatusExt;
736
737        std::process::Output {
738            status: std::process::ExitStatus::from_raw(code << 8),
739            stdout: Vec::new(),
740            stderr: stderr.to_vec(),
741        }
742    }
743
744    #[cfg(unix)]
745    #[test]
746    fn a_successful_sudo_ip_addr_del_is_released() {
747        assert_eq!(
748            classify_sudo_ip_addr_del("127.0.0.5", Ok(exited(0, b"")), || Ok(true)),
749            LoopbackRelease::Released
750        );
751    }
752
753    #[cfg(unix)]
754    #[test]
755    fn a_sudo_credentials_refusal_is_privilege_unavailable() {
756        let output = exited(1, b"sudo: a password is required\n");
757        match classify_sudo_ip_addr_del("127.0.0.5", Ok(output), || Ok(true)) {
758            LoopbackRelease::PrivilegeUnavailable(message) => {
759                assert!(message.contains("a password may be required"));
760            }
761            other => panic!("expected PrivilegeUnavailable, got {other:?}"),
762        }
763    }
764
765    #[cfg(unix)]
766    #[test]
767    fn sudo_failing_to_execute_is_privilege_unavailable() {
768        let error = std::io::Error::new(std::io::ErrorKind::NotFound, "sudo not found");
769        match classify_sudo_ip_addr_del("127.0.0.5", Err(error), || Ok(true)) {
770            LoopbackRelease::PrivilegeUnavailable(message) => {
771                assert!(message.contains("could not be executed"));
772            }
773            other => panic!("expected PrivilegeUnavailable, got {other:?}"),
774        }
775    }
776
777    #[cfg(unix)]
778    #[test]
779    fn ip_addr_del_failing_with_the_alias_still_present_is_reported_as_failed() {
780        let output = exited(2, b"RTNETLINK answers: Operation not permitted");
781        match classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || Ok(true)) {
782            LoopbackRelease::Failed(message) => {
783                assert!(message.contains("sudo -n ip addr del"));
784            }
785            other => panic!("expected Failed, got {other:?}"),
786        }
787    }
788
789    #[cfg(unix)]
790    #[test]
791    fn ip_addr_del_failing_after_the_alias_is_gone_is_released() {
792        let output = exited(2, b"RTNETLINK answers: Cannot assign requested address");
793        assert_eq!(
794            classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || Ok(false)),
795            LoopbackRelease::Released
796        );
797    }
798
799    #[cfg(unix)]
800    #[test]
801    fn an_unanswered_alias_query_keeps_the_release_retryable() {
802        let output = exited(2, b"RTNETLINK answers: Operation not permitted");
803        assert!(matches!(
804            classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || { Err(anyhow!("ip missing")) }),
805            LoopbackRelease::Failed(_)
806        ));
807    }
808
809    #[cfg(unix)]
810    #[test]
811    fn resolve_failed_removal_reports_released_when_alias_already_gone() {
812        assert_eq!(
813            resolve_failed_removal(
814                "`ip addr del` exited with exit status: 1".to_string(),
815                || { Ok(false) }
816            ),
817            LoopbackRelease::Released
818        );
819    }
820
821    #[cfg(unix)]
822    #[test]
823    fn resolve_failed_removal_keeps_failure_when_alias_still_present() {
824        assert!(matches!(
825            resolve_failed_removal(
826                "`ip addr del` exited with exit status: 1".to_string(),
827                || { Ok(true) }
828            ),
829            LoopbackRelease::Failed(_)
830        ));
831    }
832
833    #[cfg(unix)]
834    #[test]
835    fn helper_unavailable_from_the_availability_check_is_privilege_unavailable() {
836        assert!(helper_is_unavailable("Helper service is not available"));
837    }
838
839    #[cfg(unix)]
840    #[test]
841    fn a_refused_connection_is_privilege_unavailable() {
842        assert!(helper_is_unavailable(
843            "Helper failed to remove loopback address: Communication error: Failed to connect to Unix socket: Connection refused (os error 61)"
844        ));
845    }
846
847    #[cfg(unix)]
848    #[test]
849    fn a_missing_socket_file_is_privilege_unavailable() {
850        assert!(helper_is_unavailable(
851            "Helper failed to remove loopback address: Communication error: Failed to connect to Unix socket: No such file or directory (os error 2)"
852        ));
853    }
854
855    #[cfg(unix)]
856    #[test]
857    fn a_transient_write_failure_is_not_privilege_unavailable() {
858        assert!(!helper_is_unavailable(
859            "Helper failed to remove loopback address: Communication error: Failed to write request: Broken pipe (os error 32)"
860        ));
861    }
862
863    #[cfg(target_os = "linux")]
864    fn serve_one_helper_reply(
865        reply: fn(String) -> kftray_helper::messages::HelperResponse,
866    ) -> (
867        tempfile::TempDir,
868        kftray_commons::test_utils::EnvVarGuard,
869        std::thread::JoinHandle<()>,
870    ) {
871        use std::io::Write;
872        use std::os::unix::net::UnixListener;
873
874        let dir = tempfile::tempdir().unwrap();
875        let socket = dir.path().join("kftray-helper.sock");
876        let listener = UnixListener::bind(&socket).unwrap();
877        let env =
878            kftray_commons::test_utils::EnvVarGuard::set("SOCKET_PATH", socket.to_str().unwrap());
879
880        let server = std::thread::spawn(move || {
881            drop(listener.accept().unwrap());
882            let (mut stream, _) = listener.accept().unwrap();
883            let request: kftray_helper::messages::HelperRequest =
884                serde_json::Deserializer::from_reader(&mut stream)
885                    .into_iter()
886                    .next()
887                    .unwrap()
888                    .unwrap();
889            let response = reply(request.request_id);
890            stream
891                .write_all(&serde_json::to_vec(&response).unwrap())
892                .unwrap();
893        });
894
895        (dir, env, server)
896    }
897
898    #[cfg(target_os = "linux")]
899    fn helper_error(request_id: String) -> kftray_helper::messages::HelperResponse {
900        kftray_helper::messages::HelperResponse::error(
901            request_id,
902            "Error: RTNETLINK answers: Operation not permitted",
903        )
904    }
905
906    #[cfg(target_os = "linux")]
907    #[tokio::test]
908    async fn a_helper_error_reply_to_remove_is_not_a_removal() {
909        let (_dir, _env, server) = serve_one_helper_reply(helper_error);
910
911        let outcome = remove_loopback_with_helper("127.0.0.200").await;
912        server.join().unwrap();
913
914        let error = outcome.unwrap_err().to_string();
915        assert!(error.contains("Operation not permitted"), "{error}");
916    }
917
918    #[cfg(target_os = "linux")]
919    #[tokio::test]
920    async fn a_helper_error_reply_to_add_is_not_a_configuration() {
921        let (_dir, _env, server) = serve_one_helper_reply(helper_error);
922
923        let outcome = configure_loopback_with_helper("127.0.0.200").await;
924        server.join().unwrap();
925
926        let error = outcome.unwrap_err().to_string();
927        assert!(error.contains("Operation not permitted"), "{error}");
928    }
929
930    #[cfg(target_os = "linux")]
931    #[tokio::test]
932    async fn a_helper_success_reply_to_remove_is_a_removal() {
933        let (_dir, _env, server) =
934            serve_one_helper_reply(kftray_helper::messages::HelperResponse::success);
935
936        let outcome = remove_loopback_with_helper("127.0.0.200").await;
937        server.join().unwrap();
938
939        assert!(outcome.is_ok(), "{outcome:?}");
940    }
941
942    #[cfg(target_os = "windows")]
943    #[tokio::test]
944    async fn a_127_range_address_is_already_absent_without_calling_the_helper() {
945        // kftray never configures a Windows interface alias for a loopback
946        // address (`configure_loopback_windows` is a no-op), and the whole
947        // 127.0.0.0/8 range binds without one, so there is nothing to
948        // remove and no reason to call the helper.
949        assert_eq!(
950            remove_loopback_address("127.0.0.9").await.unwrap(),
951            LoopbackRelease::AlreadyAbsent
952        );
953    }
954
955    #[cfg(target_os = "windows")]
956    #[tokio::test]
957    async fn ipv6_default_loopback_is_already_absent_without_calling_the_helper() {
958        // `::1` is the IPv6 loopback that is always accessible without an
959        // interface alias, so it must be treated the same as 127.0.0.1
960        // rather than falling through to the helper-required Windows branch.
961        assert_eq!(
962            remove_loopback_address("::1").await.unwrap(),
963            LoopbackRelease::AlreadyAbsent
964        );
965    }
966
967    #[cfg(test)]
968    #[cfg(target_os = "windows")]
969    #[tokio::test]
970    async fn test_configure_loopback_windows() {
971        assert!(configure_loopback_windows("127.0.0.2").is_ok());
972    }
973}