Skip to main content

netget/
privilege.rs

1use std::net::{TcpListener, UdpSocket};
2use tracing::{debug, warn};
3
4/// A class of local hardware device a protocol needs to open.
5///
6/// Distinct from every network privilege: opening a Bluetooth adapter or a USB
7/// device is neither a port bind nor a socket, and on a correctly configured
8/// desktop it needs no elevation at all — a user in the right group, or one who has
9/// granted the app permission, simply has it. Declaring `Root` for these would be
10/// false *and* would refuse users who do have access.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum DeviceClass {
13    /// A Bluetooth adapter (BlueZ over D-Bus on Linux, CoreBluetooth on macOS)
14    Bluetooth,
15    /// A USB device (libusb / IOKit)
16    Usb,
17    /// An NFC reader (PC/SC — pcscd on Linux, the PCSC framework on macOS)
18    Nfc,
19}
20
21impl DeviceClass {
22    /// Short lowercase name used in messages
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Self::Bluetooth => "Bluetooth adapter",
26            Self::Usb => "USB device",
27            Self::Nfc => "NFC reader",
28        }
29    }
30
31    /// Platform-specific hint for a user who has been refused this device class
32    pub fn access_hint(&self) -> &'static str {
33        match self {
34            Self::Bluetooth => {
35                "Linux: install/start BlueZ and ensure a powered adapter exists \
36                 (`bluetoothctl list`). macOS: grant Bluetooth permission in \
37                 System Settings > Privacy & Security > Bluetooth."
38            }
39            Self::Usb => {
40                "Linux: add a udev rule granting your user access to the device, or \
41                 join the group owning /dev/bus/usb. macOS: no elevation is normally \
42                 needed."
43            }
44            Self::Nfc => {
45                "Linux: install and start pcscd (`systemctl start pcscd`). macOS: the \
46                 PCSC framework is built in."
47            }
48        }
49    }
50}
51
52/// System capabilities detected at startup
53#[derive(Debug, Clone)]
54pub struct SystemCapabilities {
55    /// Whether we can bind to privileged ports (< 1024)
56    pub can_bind_privileged_ports: bool,
57    /// Whether we can open **raw IP sockets** (`SOCK_RAW`) — what ICMP, IGMP and
58    /// OSPF need. On Linux this is `CAP_NET_RAW`; elsewhere it is root.
59    ///
60    /// Deliberately *not* the same thing as [`Self::has_packet_capture_access`]:
61    /// this flag used to cover both, and a single flag cannot refuse an ICMP server
62    /// for a user who only has capture rights.
63    pub has_raw_socket_access: bool,
64    /// Whether we can capture/inject at layer 2 via BPF or `AF_PACKET` — what ARP,
65    /// DataLink and IS-IS need.
66    ///
67    /// Genuinely separable from raw IP sockets: a macOS user in the ChmodBPF group
68    /// owns `/dev/bpf*` without being root, and so has capture but not raw sockets.
69    pub has_packet_capture_access: bool,
70    /// Whether a Bluetooth adapter appears to be reachable
71    pub has_bluetooth_access: bool,
72    /// Whether USB devices appear to be reachable
73    pub has_usb_access: bool,
74    /// Whether an NFC/PC-SC reader stack appears to be reachable
75    pub has_nfc_access: bool,
76    /// Whether running as root/administrator
77    pub is_root: bool,
78}
79
80impl SystemCapabilities {
81    /// Detect system capabilities at startup
82    pub fn detect() -> Self {
83        // A browser page has no root, no raw sockets and no devices; and on the virtual
84        // network of the wasm build any port number binds, so the privileged-port gate in
85        // `server_startup` must not refuse 80 or 23.
86        #[cfg(target_arch = "wasm32")]
87        {
88            return Self {
89                can_bind_privileged_ports: true,
90                has_raw_socket_access: false,
91                has_packet_capture_access: false,
92                has_bluetooth_access: false,
93                has_usb_access: false,
94                has_nfc_access: false,
95                is_root: false,
96            };
97        }
98        #[cfg(not(target_arch = "wasm32"))]
99        Self::probe()
100    }
101
102    #[cfg(not(target_arch = "wasm32"))]
103    fn probe() -> Self {
104        let is_root = is_running_as_root();
105        let can_bind_privileged_ports = can_bind_privileged_port();
106        let has_raw_socket_access = has_raw_ip_socket_capability();
107        let has_packet_capture_access = has_packet_capture_capability();
108        let has_bluetooth_access = has_bluetooth_capability();
109        let has_usb_access = has_usb_capability();
110        let has_nfc_access = has_nfc_capability();
111
112        debug!(
113            "Detected capabilities: root={}, privileged_ports={}, raw_ip_sockets={}, \
114             packet_capture={}, bluetooth={}, usb={}, nfc={}",
115            is_root,
116            can_bind_privileged_ports,
117            has_raw_socket_access,
118            has_packet_capture_access,
119            has_bluetooth_access,
120            has_usb_access,
121            has_nfc_access
122        );
123
124        Self {
125            can_bind_privileged_ports,
126            has_raw_socket_access,
127            has_packet_capture_access,
128            has_bluetooth_access,
129            has_usb_access,
130            has_nfc_access,
131            is_root,
132        }
133    }
134
135    /// Whether the given device class appears to be reachable
136    pub fn has_device_access(&self, class: DeviceClass) -> bool {
137        match class {
138            DeviceClass::Bluetooth => self.has_bluetooth_access,
139            DeviceClass::Usb => self.has_usb_access,
140            DeviceClass::Nfc => self.has_nfc_access,
141        }
142    }
143
144    /// Get a human-readable description of capabilities
145    pub fn description(&self) -> String {
146        let mut parts = Vec::new();
147
148        if self.is_root {
149            parts.push("running as root/admin");
150        }
151
152        parts.push(if self.can_bind_privileged_ports {
153            "privileged ports available"
154        } else {
155            "privileged ports unavailable"
156        });
157
158        parts.push(if self.has_raw_socket_access {
159            "raw IP sockets available"
160        } else {
161            "raw IP sockets unavailable"
162        });
163
164        parts.push(if self.has_packet_capture_access {
165            "packet capture available"
166        } else {
167            "packet capture unavailable"
168        });
169
170        // Device access is only worth mentioning when something is missing: on a
171        // normal desktop all three are present and the line is noise.
172        if !self.has_bluetooth_access {
173            parts.push("no Bluetooth adapter");
174        }
175        if !self.has_usb_access {
176            parts.push("no USB access");
177        }
178        if !self.has_nfc_access {
179            parts.push("no NFC reader stack");
180        }
181
182        parts.join(", ")
183    }
184}
185
186/// Check if running as root/administrator
187fn is_running_as_root() -> bool {
188    #[cfg(unix)]
189    {
190        // geteuid() is the actual answer. The previous implementation stat'd /root
191        // and compared $USER/$LOGNAME, which is wrong in both directions: it reported
192        // root under `sudo -E` (which preserves $USER) or in any container with a
193        // world-readable /root, and reported non-root for a root account whose home
194        // is not /root.
195        //
196        // SAFETY: geteuid() takes no arguments, cannot fail, and touches no memory.
197        unsafe { libc::geteuid() == 0 }
198    }
199
200    #[cfg(windows)]
201    {
202        // On Windows, check if running as Administrator
203        // For now, return false - can be enhanced later with Windows-specific APIs
204        false
205    }
206
207    #[cfg(not(any(unix, windows)))]
208    {
209        false
210    }
211}
212
213/// What a single privileged-port bind attempt actually proved.
214///
215/// The distinction exists because a failed bind is not one fact but three, and the
216/// original probe collapsed them: it tried 80, 67, 123 and 53, and returned `false` if
217/// none bound. A developer machine with anything already listening on those ports —
218/// a local web server on 80, `dnsmasq` on 53, an NTP daemon on 123 — was therefore
219/// reported as unable to bind privileged ports, and `server_startup` refused to start
220/// servers the machine could in fact run. "Someone else holds this port" says nothing
221/// whatsoever about this process's privilege.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum PortProbe {
224    /// The bind succeeded: proof the process *can* bind privileged ports.
225    Bound,
226    /// `EACCES`/`EPERM`: proof it *cannot*.
227    Denied,
228    /// `EADDRINUSE`/`EADDRNOTAVAIL`, or any other error: the port was unavailable for
229    /// reasons unrelated to privilege, so this attempt proved nothing either way.
230    Inconclusive,
231}
232
233/// Classify a failed `bind(2)` by what it says about privilege.
234pub fn classify_bind_error(err: &std::io::Error) -> PortProbe {
235    match err.kind() {
236        std::io::ErrorKind::PermissionDenied => PortProbe::Denied,
237        // AddrInUse: another process holds it. AddrNotAvailable: the address is not
238        // ours to bind. Neither is a statement about privilege.
239        std::io::ErrorKind::AddrInUse | std::io::ErrorKind::AddrNotAvailable => {
240            PortProbe::Inconclusive
241        }
242        _ => PortProbe::Inconclusive,
243    }
244}
245
246/// Decide the capability from a set of probe outcomes.
247///
248/// - Any `Bound` ⇒ capable. One success is proof.
249/// - Otherwise any `Denied` ⇒ not capable. The kernel said so explicitly.
250/// - Otherwise (every probe inconclusive, e.g. every test port already in use) the
251///   honest answer is **unknown**, and we report `true` — capable.
252///
253/// That last choice is deliberate. This flag *hard-gates* startup in
254/// `server_startup.rs`, so mapping "unknown" to `false` turns "we could not tell" into
255/// a refusal, which is the failure mode the capability probes were rewritten to
256/// eliminate. Reporting `true` on unknown costs nothing worse than the real `bind`
257/// failing a moment later with its own `EACCES`, which is a clear, specific, actionable
258/// error — strictly better than refusing a machine that would have worked. Note this is
259/// a *capability* probe, not an authorization decision: the permissive default here
260/// grants no access that the kernel would not independently grant.
261pub fn privileged_port_capability(probes: &[PortProbe]) -> bool {
262    if probes.contains(&PortProbe::Bound) {
263        return true;
264    }
265    if probes.contains(&PortProbe::Denied) {
266        return false;
267    }
268    true
269}
270
271/// Check if we can bind to privileged ports (< 1024)
272///
273/// Probes several well-known privileged ports and classifies each failure; see
274/// [`PortProbe`] and [`privileged_port_capability`] for why the failures must be
275/// told apart.
276fn can_bind_privileged_port() -> bool {
277    // If we're root, we definitely can
278    if is_running_as_root() {
279        return true;
280    }
281
282    // Try to bind to common privileged ports
283    // Use a quick test bind that doesn't actually listen
284    let test_ports = [80, 67, 123, 53];
285    let mut probes = Vec::with_capacity(test_ports.len() * 2);
286
287    for port in test_ports {
288        let addr = format!("127.0.0.1:{}", port);
289
290        // Try TCP first
291        match TcpListener::bind(&addr) {
292            Ok(listener) => {
293                drop(listener);
294                debug!("Successfully test-bound to privileged TCP port {}", port);
295                return true;
296            }
297            Err(e) => {
298                let outcome = classify_bind_error(&e);
299                debug!(
300                    "TCP bind to privileged port {} failed: {} ({:?})",
301                    port, e, outcome
302                );
303                probes.push(outcome);
304            }
305        }
306
307        // Try UDP
308        match UdpSocket::bind(&addr) {
309            Ok(socket) => {
310                drop(socket);
311                debug!("Successfully test-bound to privileged UDP port {}", port);
312                return true;
313            }
314            Err(e) => {
315                let outcome = classify_bind_error(&e);
316                debug!(
317                    "UDP bind to privileged port {} failed: {} ({:?})",
318                    port, e, outcome
319                );
320                probes.push(outcome);
321            }
322        }
323    }
324
325    // If all test binds failed, check if it's because of privileges or port in use
326    // Try binding to a high port to make sure networking works
327    if TcpListener::bind("127.0.0.1:0").is_err() {
328        warn!("Cannot bind to any ports - networking may be broken");
329    }
330
331    let capable = privileged_port_capability(&probes);
332    if capable {
333        warn!(
334            "Every privileged test port ({:?}) was unavailable for reasons other than \
335             permission - assuming privileged ports are bindable. A server on a port \
336             below 1024 will report the real error if this assumption is wrong.",
337            test_ports
338        );
339    } else {
340        debug!("Cannot bind to privileged ports - requires elevated privileges");
341    }
342    capable
343}
344
345/// Check whether this process can open a **raw IP socket** (`SOCK_RAW`).
346///
347/// This is the capability ICMP, IGMP and OSPF need, and it must be *probed*, not
348/// inferred. The original implementation called `pcap::Device::list()`, which is a
349/// thin wrapper over `getifaddrs(3)` and succeeds for any unprivileged user — so it
350/// reported `true` unconditionally, which in turn made the pre-flight in
351/// `server_startup` never fire. Every raw-socket protocol then failed later with an
352/// opaque `EPERM` from its own socket call instead of a clear refusal at startup.
353///
354/// Layer-2 capture is a *separate* capability; see
355/// [`has_packet_capture_capability`]. Merging the two — as a single flag once did —
356/// makes it impossible to refuse an ICMP server for a capture-only user.
357fn has_raw_ip_socket_capability() -> bool {
358    #[cfg(unix)]
359    {
360        if is_running_as_root() {
361            return true;
362        }
363
364        // A raw IP socket is the definitive test: it succeeds exactly with
365        // CAP_NET_RAW on Linux, and only as root on macOS/BSD. Note SOCK_RAW
366        // specifically — Linux lets unprivileged users open SOCK_DGRAM ICMP sockets
367        // via ping_group_range, which is not the capability these protocols need.
368        //
369        // SAFETY: socket(2) with constant arguments; the fd is closed immediately
370        // on success and nothing else touches it.
371        let raw_fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_RAW, libc::IPPROTO_ICMP) };
372        if raw_fd >= 0 {
373            unsafe { libc::close(raw_fd) };
374            debug!("Opened a raw ICMP socket - raw IP socket access available");
375            return true;
376        }
377
378        debug!(
379            "Raw ICMP socket failed ({}) and not root - raw-socket protocols \
380             (ICMP, IGMP, OSPF) will be refused at startup.",
381            std::io::Error::last_os_error()
382        );
383        false
384    }
385
386    #[cfg(windows)]
387    {
388        is_running_as_root()
389    }
390
391    #[cfg(not(any(unix, windows)))]
392    {
393        false
394    }
395}
396
397/// Check whether this process can capture or inject frames at layer 2.
398///
399/// This is what ARP, DataLink and IS-IS need, and it is genuinely distinct from a
400/// raw IP socket: a macOS user in the ChmodBPF group owns `/dev/bpf*` and can
401/// capture without being root, while still being unable to open `SOCK_RAW`.
402fn has_packet_capture_capability() -> bool {
403    #[cfg(unix)]
404    {
405        if is_running_as_root() {
406            return true;
407        }
408
409        // Linux: AF_PACKET is the capture path, gated on CAP_NET_RAW.
410        //
411        // SAFETY: socket(2) with constant arguments; the fd is closed immediately
412        // on success and nothing else touches it.
413        #[cfg(target_os = "linux")]
414        {
415            let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, 0) };
416            if fd >= 0 {
417                unsafe { libc::close(fd) };
418                debug!("Opened an AF_PACKET socket - L2 capture available");
419                return true;
420            }
421        }
422
423        // macOS/BSD: capture goes through a /dev/bpf* clone device, whose
424        // permissions can be relaxed independently of root (ChmodBPF).
425        //
426        // **A BPF device is exclusive: one open handle each.** So "could not open
427        // /dev/bpf0" says nothing about permission when some other capture already
428        // holds it — libpcap itself answers this by scanning upward until it finds a
429        // free one, and this probe has to do the same or it will disagree with the
430        // library it is gating.
431        //
432        // Scanning only 0..4 made the answer depend on load. Running the suite at
433        // --test-threads=100, or alongside another netget, occupies the first few
434        // devices and the probe then reported "no capture access" on a host that
435        // plainly had it — `is_met_by()` refuses ARP, DataLink and IS-IS at startup on
436        // that answer, so those servers became un-startable whenever the machine was
437        // busy. It showed up as `isis_spawn_outcome_matches_capture_privilege` failing
438        // with "spawn returned Ok without capture privilege": the capture opened fine,
439        // and only the probe disagreed.
440        //
441        // `EBUSY` is therefore *positive* evidence — the device exists and we were
442        // allowed to try, it is merely in use. Only `EACCES`/`EPERM` on every device we
443        // can see is a real denial.
444        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
445        {
446            let mut denied = false;
447            for n in 0..256 {
448                match std::fs::File::open(format!("/dev/bpf{}", n)) {
449                    Ok(_) => {
450                        debug!("Opened /dev/bpf{} - L2 capture available without root", n);
451                        return true;
452                    }
453                    Err(e) => match e.raw_os_error() {
454                        // In use by someone else: we were permitted to try, which is
455                        // what the caller is actually asking about.
456                        Some(libc::EBUSY) => {
457                            debug!("/dev/bpf{} is busy - capture access is present", n);
458                            return true;
459                        }
460                        Some(libc::EACCES) | Some(libc::EPERM) => {
461                            denied = true;
462                            continue;
463                        }
464                        // Past the last device the system created.
465                        Some(libc::ENOENT) | Some(libc::ENXIO) => break,
466                        _ => continue,
467                    },
468                }
469            }
470            if denied {
471                debug!("every /dev/bpf* refused permission - no L2 capture access");
472            }
473        }
474
475        debug!(
476            "No capture handle available ({}) and not root - capture protocols \
477             (ARP, DataLink, IS-IS) will be refused at startup.",
478            std::io::Error::last_os_error()
479        );
480        false
481    }
482
483    #[cfg(windows)]
484    {
485        // Npcap/WinPcap driver access requires Administrator.
486        is_running_as_root()
487    }
488
489    #[cfg(not(any(unix, windows)))]
490    {
491        false
492    }
493}
494
495/// Whether any of the given paths exists (used by the Linux device probes).
496#[cfg(target_os = "linux")]
497fn any_path_exists(paths: &[&str]) -> bool {
498    paths.iter().any(|p| std::path::Path::new(p).exists())
499}
500
501/// Probe for a usable Bluetooth adapter.
502///
503/// Device probes are deliberately **permissive**: they return `false` only on
504/// positive evidence that the stack is absent, never on "could not tell". Refusing
505/// a start is a hard failure, and the alternative — the protocol failing later with
506/// its own diagnostic — is strictly better than refusing a user who does have
507/// access.
508fn has_bluetooth_capability() -> bool {
509    #[cfg(target_os = "linux")]
510    {
511        // BlueZ is reached over the D-Bus system bus, and an adapter shows up under
512        // /sys/class/bluetooth. Both are needed; neither requires root.
513        let bus = any_path_exists(&[
514            "/run/dbus/system_bus_socket",
515            "/var/run/dbus/system_bus_socket",
516        ]);
517        let adapter = std::fs::read_dir("/sys/class/bluetooth")
518            .map(|mut d| d.next().is_some())
519            .unwrap_or(false);
520        if !bus {
521            debug!("No D-Bus system bus socket - BlueZ is unreachable");
522        } else if !adapter {
523            debug!("No adapter under /sys/class/bluetooth");
524        }
525        bus && adapter
526    }
527
528    // macOS gates CoreBluetooth through TCC, which cannot be probed without
529    // triggering the permission prompt, so assume available and let the protocol
530    // report the real error. Same for any platform we have no probe for.
531    #[cfg(not(target_os = "linux"))]
532    {
533        true
534    }
535}
536
537/// Probe for usable USB device access (see [`has_bluetooth_capability`] on why
538/// these probes are permissive).
539fn has_usb_capability() -> bool {
540    #[cfg(target_os = "linux")]
541    {
542        // libusb enumerates through /dev/bus/usb; per-device write access is a udev
543        // matter we cannot check generically, so existence is the whole test.
544        let present = std::path::Path::new("/dev/bus/usb").is_dir();
545        if !present {
546            debug!("/dev/bus/usb is absent - usbfs is not mounted");
547        }
548        present
549    }
550
551    // macOS reaches USB through IOKit, which needs no elevation for the device
552    // classes NetGet uses.
553    #[cfg(not(target_os = "linux"))]
554    {
555        true
556    }
557}
558
559/// Probe for a usable NFC/PC-SC reader stack (see [`has_bluetooth_capability`] on
560/// why these probes are permissive).
561fn has_nfc_capability() -> bool {
562    #[cfg(target_os = "linux")]
563    {
564        // The pcsc crate talks to pcscd over its socket; without the daemon running
565        // there is nothing to talk to.
566        let present = any_path_exists(&["/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"]);
567        if !present {
568            debug!("No pcscd socket - the PC/SC daemon is not running");
569        }
570        present
571    }
572
573    // macOS ships the PCSC framework; other platforms are not probed.
574    #[cfg(not(target_os = "linux"))]
575    {
576        true
577    }
578}