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}