Skip to main content

netget/protocol/
dependencies.rs

1//! Protocol dependency system
2//!
3//! Defines runtime dependencies that protocols may require and provides
4//! checking logic to determine if dependencies are available.
5
6use std::process::Command;
7use tracing::debug;
8
9#[cfg(not(any(target_os = "linux", target_os = "macos")))]
10use tracing::warn;
11
12/// What a dependency probe could establish.
13///
14/// Three answers, not two, because the startup gate must be able to tell "absent" from "could
15/// not tell": only [`DependencyStatus::Missing`] refuses a start. A probe that cannot run —
16/// no `PATH` to search, a platform with no loader query — answers
17/// [`DependencyStatus::Unknown`], and that is logged and let through, never turned into "no".
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum DependencyStatus {
20    /// The probe found it.
21    Available,
22    /// The probe ran and established it is not there.
23    Missing,
24    /// The probe could not answer; the string says why.
25    Unknown(String),
26}
27
28/// A runtime dependency that a protocol requires
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum ProtocolDependency {
31    /// Requires a system library (e.g., libpcap, libsmbclient, libgit2)
32    /// The string is the library name without prefix/suffix (e.g., "pcap" not "libpcap.so")
33    SystemLibrary(&'static str),
34
35    /// Requires a tool to be available in PATH (e.g., "protoc", "openssl")
36    ToolInPath(&'static str),
37
38    /// Requires raw socket access (CAP_NET_RAW on Linux, or root)
39    RawSocketAccess,
40
41    /// Requires ability to bind to a privileged port < 1024
42    PrivilegedPort(u16),
43
44    /// Requires full root/administrator access
45    RootAccess,
46
47    /// Requires TUN/TAP device creation (CAP_NET_ADMIN on Linux, or root)
48    /// Used by VPN protocols like WireGuard, OpenVPN
49    TunDeviceAccess,
50
51    /// Requires promiscuous mode access (CAP_NET_RAW on Linux, or root)
52    /// Used by packet capture protocols like ARP, DataLink
53    PromiscuousMode,
54}
55
56impl ProtocolDependency {
57    /// Get a human-readable name for this dependency
58    pub fn name(&self) -> String {
59        match self {
60            Self::SystemLibrary(name) => format!("lib{}", name),
61            Self::ToolInPath(name) => name.to_string(),
62            Self::RawSocketAccess => "raw socket access".to_string(),
63            Self::PrivilegedPort(port) => format!("port {}", port),
64            Self::RootAccess => "root access".to_string(),
65            Self::TunDeviceAccess => "TUN device access".to_string(),
66            Self::PromiscuousMode => "promiscuous mode".to_string(),
67        }
68    }
69
70    /// Get a human-readable description of this dependency
71    pub fn description(&self) -> String {
72        match self {
73            Self::SystemLibrary(name) => {
74                format!("System library lib{} must be installed", name)
75            }
76            Self::ToolInPath(name) => {
77                format!("Tool '{}' must be available in PATH", name)
78            }
79            Self::RawSocketAccess => {
80                "Raw socket access (requires root or CAP_NET_RAW capability)".to_string()
81            }
82            Self::PrivilegedPort(port) => {
83                format!(
84                    "Privileged port {} (requires root or CAP_NET_BIND_SERVICE)",
85                    port
86                )
87            }
88            Self::RootAccess => "Root/Administrator access required".to_string(),
89            Self::TunDeviceAccess => {
90                "TUN/TAP device creation (requires root or CAP_NET_ADMIN)".to_string()
91            }
92            Self::PromiscuousMode => "Promiscuous mode (requires root or CAP_NET_RAW)".to_string(),
93        }
94    }
95
96    /// Whether this dependency is present, for the informational consumers — the TUI's
97    /// excluded-protocol footer and the model's protocol list.
98    ///
99    /// Only a definite [`DependencyStatus::Missing`] reads as unavailable: a probe that could
100    /// not answer must not hide a protocol that may well work (the reason `DeviceAccess` derives
101    /// no dependency at all).
102    pub fn is_available(&self, caps: &crate::privilege::SystemCapabilities) -> bool {
103        self.status(caps) != DependencyStatus::Missing
104    }
105
106    /// What the probe for this dependency established. See [`DependencyStatus`].
107    pub fn status(&self, caps: &crate::privilege::SystemCapabilities) -> DependencyStatus {
108        match self {
109            Self::SystemLibrary(name) => system_library_status(name),
110            Self::ToolInPath(name) => tool_in_path_status(name),
111            other => {
112                if other.is_met_by_capabilities(caps) {
113                    DependencyStatus::Available
114                } else {
115                    DependencyStatus::Missing
116                }
117            }
118        }
119    }
120
121    /// The capability-derived variants, answered from the probed [`SystemCapabilities`].
122    ///
123    /// [`SystemCapabilities`]: crate::privilege::SystemCapabilities
124    fn is_met_by_capabilities(&self, caps: &crate::privilege::SystemCapabilities) -> bool {
125        match self {
126            Self::SystemLibrary(_) | Self::ToolInPath(_) => true,
127            Self::RawSocketAccess => caps.has_raw_socket_access,
128            Self::PrivilegedPort(_) => caps.can_bind_privileged_ports,
129            Self::RootAccess => caps.is_root,
130            Self::TunDeviceAccess => {
131                // TUN device creation requires CAP_NET_ADMIN on Linux, or root on other platforms
132                #[cfg(target_os = "linux")]
133                {
134                    caps.is_root || check_cap_net_admin()
135                }
136                #[cfg(not(target_os = "linux"))]
137                {
138                    caps.is_root
139                }
140            }
141            // Capture access, not raw-socket access. These are genuinely separable: a macOS
142            // user in the ChmodBPF group owns /dev/bpf* without being root, so they can capture
143            // (ARP, DataLink, IS-IS) while `SOCK_RAW` still fails. Checking the raw-socket flag
144            // here reported those protocols as unavailable to precisely the users who can run
145            // them — the mistake `SystemCapabilities` was split apart to make impossible.
146            Self::PromiscuousMode => caps.has_packet_capture_access,
147        }
148    }
149
150    /// Get installation instructions for this dependency
151    pub fn installation_hint(&self) -> String {
152        match self {
153            Self::SystemLibrary(name) => {
154                #[cfg(target_os = "linux")]
155                {
156                    match *name {
157                        "pcap" => "Install with: apt-get install libpcap-dev (Debian/Ubuntu) or yum install libpcap-devel (RHEL/CentOS)".to_string(),
158                        "smbclient" => "Install with: apt-get install libsmbclient-dev (Debian/Ubuntu)".to_string(),
159                        "git2" => "Install with: apt-get install libgit2-dev (Debian/Ubuntu)".to_string(),
160                        _ => format!("Install with: apt-get install lib{}-dev (Debian/Ubuntu)", name),
161                    }
162                }
163                #[cfg(target_os = "macos")]
164                {
165                    match *name {
166                        "pcap" => "Install with: brew install libpcap".to_string(),
167                        "smbclient" => "Install with: brew install samba".to_string(),
168                        "git2" => "Install with: brew install libgit2".to_string(),
169                        _ => format!("Install with: brew install lib{}", name),
170                    }
171                }
172                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
173                {
174                    format!("Install lib{} for your platform", name)
175                }
176            }
177            Self::ToolInPath(name) => {
178                match *name {
179                    "protoc" => "Install from: https://github.com/protocolbuffers/protobuf/releases or use package manager".to_string(),
180                    _ => format!("Ensure '{}' is installed and in PATH", name),
181                }
182            }
183            Self::RawSocketAccess => {
184                #[cfg(target_os = "linux")]
185                {
186                    "Grant capability with: sudo setcap cap_net_raw+ep /path/to/netget".to_string()
187                }
188                #[cfg(not(target_os = "linux"))]
189                {
190                    "Run as root/administrator".to_string()
191                }
192            }
193            Self::PrivilegedPort(port) => {
194                format!("Run as root/administrator, or use an unprivileged port (>= 1024) instead of {}", port)
195            }
196            Self::RootAccess => "Run as root/administrator".to_string(),
197            Self::TunDeviceAccess => {
198                #[cfg(target_os = "linux")]
199                {
200                    "Grant capability with: sudo setcap cap_net_admin+ep /path/to/netget".to_string()
201                }
202                #[cfg(not(target_os = "linux"))]
203                {
204                    "Run as root/administrator".to_string()
205                }
206            }
207            Self::PromiscuousMode => {
208                #[cfg(target_os = "linux")]
209                {
210                    "Grant capability with: sudo setcap cap_net_raw+ep /path/to/netget".to_string()
211                }
212                #[cfg(not(target_os = "linux"))]
213                {
214                    "Run as root/administrator".to_string()
215                }
216            }
217        }
218    }
219}
220
221/// Ask the dynamic linker whether this process can load a library.
222///
223/// This is the honest form of the question. A filesystem search answers "is there a
224/// file with this name in one of the directories I guessed", which on modern macOS is
225/// **always no** for a system library: since Big Sur the system libraries ship only
226/// inside the dyld shared cache and `/usr/lib/libpcap.dylib` does not exist on disk,
227/// even though `dlopen("libpcap.dylib")` succeeds and every binary on the machine links
228/// against it. `dlopen` consults the cache, honours `DYLD_*`/`LD_LIBRARY_PATH`, and
229/// resolves whatever the loader would resolve at link time — which is precisely the
230/// property a dependency check is trying to establish.
231///
232/// The handle is released immediately; the point is the answer, not the library.
233#[cfg(unix)]
234fn dlopen_succeeds(soname: &str) -> bool {
235    let Ok(cname) = std::ffi::CString::new(soname) else {
236        return false;
237    };
238
239    // SAFETY: `cname` is a valid NUL-terminated C string that outlives the call, and
240    // the returned handle is closed immediately. `RTLD_LOCAL` keeps the symbols out of
241    // the global namespace so a probe cannot change how later symbol lookups resolve.
242    unsafe {
243        let handle = libc::dlopen(cname.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL);
244        if handle.is_null() {
245            false
246        } else {
247            libc::dlclose(handle);
248            true
249        }
250    }
251}
252
253/// The names to try with the dynamic linker, most conventional first.
254///
255/// Linux only carries the unversioned `lib<name>.so` symlink when the `-dev` package is
256/// installed, so the versioned sonames are tried too — a runtime-only install is still
257/// a library this process can load.
258#[cfg(unix)]
259fn library_candidates(name: &str) -> Vec<String> {
260    #[cfg(target_os = "macos")]
261    {
262        vec![
263            format!("lib{}.dylib", name),
264            format!("{}.dylib", name),
265            format!("/usr/lib/lib{}.dylib", name),
266            format!("/opt/homebrew/lib/lib{}.dylib", name),
267            format!("/usr/local/lib/lib{}.dylib", name),
268        ]
269    }
270
271    #[cfg(not(target_os = "macos"))]
272    {
273        vec![
274            format!("lib{}.so", name),
275            format!("lib{}.so.1", name),
276            format!("lib{}.so.0", name),
277            format!("lib{}.so.2", name),
278        ]
279    }
280}
281
282/// Probe for a system library.
283///
284/// `dlopen` success is **Available**. When the loader and the platform's own catalogue
285/// (`ldconfig -p`, `pkg-config`) both ran and neither knows the library, that is **Missing**. When
286/// no catalogue could be run at all, or the platform has no probe, the answer is **Unknown**.
287fn system_library_status(name: &str) -> DependencyStatus {
288    // Ask the dynamic linker first on every Unix. This is the only probe that is
289    // correct on macOS at all (see `dlopen_succeeds`), and on Linux it subsumes the
290    // ldconfig search for the common case while also honouring LD_LIBRARY_PATH.
291    #[cfg(unix)]
292    {
293        for candidate in library_candidates(name) {
294            if dlopen_succeeds(&candidate) {
295                debug!("dlopen({}) succeeded - lib{} is loadable", candidate, name);
296                return DependencyStatus::Available;
297            }
298        }
299        debug!(
300            "dlopen found no loadable lib{}; falling back to platform probes",
301            name
302        );
303    }
304
305    // Try to use ldconfig to check if library is available
306    #[cfg(target_os = "linux")]
307    {
308        let output = Command::new("ldconfig").arg("-p").output();
309
310        if let Ok(output) = output {
311            let stdout = String::from_utf8_lossy(&output.stdout);
312            let lib_pattern = format!("lib{}.so", name);
313            let found = stdout.contains(&lib_pattern);
314            debug!("Checking for lib{}: found={}", name, found);
315            return if found {
316                DependencyStatus::Available
317            } else {
318                DependencyStatus::Missing
319            };
320        }
321
322        // Fallback: try pkg-config
323        let pkg_config_output = Command::new("pkg-config")
324            .arg("--exists")
325            .arg(name)
326            .status();
327
328        if let Ok(status) = pkg_config_output {
329            let found = status.success();
330            debug!("Checking for {} via pkg-config: found={}", name, found);
331            return if found {
332                DependencyStatus::Available
333            } else {
334                DependencyStatus::Missing
335            };
336        }
337
338        debug!(
339            "Could not check for lib{} (ldconfig and pkg-config unavailable)",
340            name
341        );
342        DependencyStatus::Unknown(format!(
343            "dlopen found no lib{name} and neither ldconfig nor pkg-config could be run"
344        ))
345    }
346
347    #[cfg(target_os = "macos")]
348    {
349        // No filesystem search here on purpose. The previous implementation ran
350        // `find /usr/lib /usr/local/lib /opt/homebrew/lib -name lib<name>.dylib`, which
351        // returns nothing for every library that ships in the dyld shared cache — so
352        // libpcap, which is present and links fine on this platform, was reported
353        // missing, and any protocol declaring SystemLibrary("pcap") would have been
354        // refused at startup on exactly the OS where it works.
355        //
356        // dlopen above is the real test. pkg-config is kept only as a last resort for
357        // a Homebrew-only library whose dylib name does not follow the convention.
358        let pkg_config_output = Command::new("pkg-config")
359            .arg("--exists")
360            .arg(name)
361            .status();
362
363        if let Ok(status) = pkg_config_output {
364            let found = status.success();
365            debug!("Checking for {} via pkg-config: found={}", name, found);
366            return if found {
367                DependencyStatus::Available
368            } else {
369                DependencyStatus::Missing
370            };
371        }
372
373        // dlopen is the loader's own answer; with no pkg-config to consult there is no
374        // second opinion, and the loader not finding it under any conventional name is as
375        // definite as this platform gets.
376        debug!("dlopen and pkg-config found no lib{} on macOS", name);
377        DependencyStatus::Missing
378    }
379
380    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
381    {
382        // No loader query or package catalogue on this platform.
383        warn!(
384            "Cannot check for lib{} on this platform; not treating it as missing",
385            name
386        );
387        DependencyStatus::Unknown(format!("no library probe for lib{name} on this platform"))
388    }
389}
390
391/// Probe for an executable on `PATH`, searching it the way `Command::new(tool)` will.
392///
393/// This walks `PATH` itself rather than running `which`: the question is whether the protocol's
394/// own `Command::new(tool)` will find a program, and a `which` that cannot be spawned (no
395/// `PATH`, a minimal container) used to read as "the tool is missing". An unset `PATH` is
396/// **Unknown**; a `PATH` searched in full without finding an executable `tool` is **Missing**.
397fn tool_in_path_status(tool: &str) -> DependencyStatus {
398    let Some(path) = std::env::var_os("PATH") else {
399        return DependencyStatus::Unknown("PATH is not set, so there is nothing to search".into());
400    };
401
402    #[cfg(windows)]
403    let names: Vec<String> = ["", ".exe", ".cmd", ".bat", ".com"]
404        .iter()
405        .map(|ext| format!("{tool}{ext}"))
406        .collect();
407    #[cfg(not(windows))]
408    let names: Vec<String> = vec![tool.to_string()];
409
410    for dir in std::env::split_paths(&path) {
411        if dir.as_os_str().is_empty() {
412            continue;
413        }
414        for name in &names {
415            let candidate = dir.join(name);
416            if is_executable_file(&candidate) {
417                debug!("Found tool '{}' at {}", tool, candidate.display());
418                return DependencyStatus::Available;
419            }
420        }
421    }
422    debug!("Tool '{}' is not on PATH", tool);
423    DependencyStatus::Missing
424}
425
426/// A regular file (symlinks followed) the current user may execute.
427fn is_executable_file(path: &std::path::Path) -> bool {
428    let Ok(metadata) = std::fs::metadata(path) else {
429        return false;
430    };
431    if !metadata.is_file() {
432        return false;
433    }
434    #[cfg(unix)]
435    {
436        use std::os::unix::fs::PermissionsExt;
437        metadata.permissions().mode() & 0o111 != 0
438    }
439    #[cfg(not(unix))]
440    {
441        true
442    }
443}
444
445/// The dependency that must stop a start, if any: the first **system library or tool** among
446/// `deps` whose probe established it is absent.
447///
448/// Dependencies derived from `privilege_requirement` are skipped — startup gates privilege
449/// itself, with a better message, and checking them here would report one failure twice. A
450/// probe that could not answer ([`DependencyStatus::Unknown`]) is logged and let through:
451/// dependencies inform, and "we could not tell" must never become a refusal.
452pub fn startup_blocker(
453    deps: &[ProtocolDependency],
454    caps: &crate::privilege::SystemCapabilities,
455) -> Option<ProtocolDependency> {
456    for dep in deps {
457        if !matches!(
458            dep,
459            ProtocolDependency::SystemLibrary(_) | ProtocolDependency::ToolInPath(_)
460        ) {
461            continue;
462        }
463        match dep.status(caps) {
464            DependencyStatus::Available => {}
465            DependencyStatus::Missing => return Some(dep.clone()),
466            DependencyStatus::Unknown(why) => {
467                tracing::warn!(
468                    "Could not determine whether {} is present ({}); starting anyway",
469                    dep.name(),
470                    why
471                );
472            }
473        }
474    }
475    None
476}
477
478/// Check for CAP_NET_ADMIN capability on Linux
479#[cfg(target_os = "linux")]
480fn check_cap_net_admin() -> bool {
481    if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
482        for line in status.lines() {
483            if line.starts_with("CapEff:") {
484                if let Some(cap_hex) = line.split_whitespace().nth(1) {
485                    if let Ok(caps) = u64::from_str_radix(cap_hex, 16) {
486                        // CAP_NET_ADMIN is bit 12 (0x1000)
487                        const CAP_NET_ADMIN: u64 = 1 << 12;
488                        let has_cap = (caps & CAP_NET_ADMIN) != 0;
489                        debug!(
490                            "Checked CAP_NET_ADMIN via /proc: caps=0x{:x}, has_cap={}",
491                            caps, has_cap
492                        );
493                        return has_cap;
494                    }
495                }
496            }
497        }
498    }
499
500    debug!("Could not detect CAP_NET_ADMIN capability");
501    false
502}