netget-tool 0.3.5

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
//! Protocol dependency system
//!
//! Defines runtime dependencies that protocols may require and provides
//! checking logic to determine if dependencies are available.

use std::process::Command;
use tracing::debug;

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
use tracing::warn;

/// What a dependency probe could establish.
///
/// Three answers, not two, because the startup gate must be able to tell "absent" from "could
/// not tell": only [`DependencyStatus::Missing`] refuses a start. A probe that cannot run —
/// no `PATH` to search, a platform with no loader query — answers
/// [`DependencyStatus::Unknown`], and that is logged and let through, never turned into "no".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencyStatus {
    /// The probe found it.
    Available,
    /// The probe ran and established it is not there.
    Missing,
    /// The probe could not answer; the string says why.
    Unknown(String),
}

/// A runtime dependency that a protocol requires
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProtocolDependency {
    /// Requires a system library (e.g., libpcap, libsmbclient, libgit2)
    /// The string is the library name without prefix/suffix (e.g., "pcap" not "libpcap.so")
    SystemLibrary(&'static str),

    /// Requires a tool to be available in PATH (e.g., "protoc", "openssl")
    ToolInPath(&'static str),

    /// Requires raw socket access (CAP_NET_RAW on Linux, or root)
    RawSocketAccess,

    /// Requires ability to bind to a privileged port < 1024
    PrivilegedPort(u16),

    /// Requires full root/administrator access
    RootAccess,

    /// Requires TUN/TAP device creation (CAP_NET_ADMIN on Linux, or root)
    /// Used by VPN protocols like WireGuard, OpenVPN
    TunDeviceAccess,

    /// Requires promiscuous mode access (CAP_NET_RAW on Linux, or root)
    /// Used by packet capture protocols like ARP, DataLink
    PromiscuousMode,
}

impl ProtocolDependency {
    /// Get a human-readable name for this dependency
    pub fn name(&self) -> String {
        match self {
            Self::SystemLibrary(name) => format!("lib{}", name),
            Self::ToolInPath(name) => name.to_string(),
            Self::RawSocketAccess => "raw socket access".to_string(),
            Self::PrivilegedPort(port) => format!("port {}", port),
            Self::RootAccess => "root access".to_string(),
            Self::TunDeviceAccess => "TUN device access".to_string(),
            Self::PromiscuousMode => "promiscuous mode".to_string(),
        }
    }

    /// Get a human-readable description of this dependency
    pub fn description(&self) -> String {
        match self {
            Self::SystemLibrary(name) => {
                format!("System library lib{} must be installed", name)
            }
            Self::ToolInPath(name) => {
                format!("Tool '{}' must be available in PATH", name)
            }
            Self::RawSocketAccess => {
                "Raw socket access (requires root or CAP_NET_RAW capability)".to_string()
            }
            Self::PrivilegedPort(port) => {
                format!(
                    "Privileged port {} (requires root or CAP_NET_BIND_SERVICE)",
                    port
                )
            }
            Self::RootAccess => "Root/Administrator access required".to_string(),
            Self::TunDeviceAccess => {
                "TUN/TAP device creation (requires root or CAP_NET_ADMIN)".to_string()
            }
            Self::PromiscuousMode => "Promiscuous mode (requires root or CAP_NET_RAW)".to_string(),
        }
    }

    /// Whether this dependency is present, for the informational consumers — the TUI's
    /// excluded-protocol footer and the model's protocol list.
    ///
    /// Only a definite [`DependencyStatus::Missing`] reads as unavailable: a probe that could
    /// not answer must not hide a protocol that may well work (the reason `DeviceAccess` derives
    /// no dependency at all).
    pub fn is_available(&self, caps: &crate::privilege::SystemCapabilities) -> bool {
        self.status(caps) != DependencyStatus::Missing
    }

    /// What the probe for this dependency established. See [`DependencyStatus`].
    pub fn status(&self, caps: &crate::privilege::SystemCapabilities) -> DependencyStatus {
        match self {
            Self::SystemLibrary(name) => system_library_status(name),
            Self::ToolInPath(name) => tool_in_path_status(name),
            other => {
                if other.is_met_by_capabilities(caps) {
                    DependencyStatus::Available
                } else {
                    DependencyStatus::Missing
                }
            }
        }
    }

    /// The capability-derived variants, answered from the probed [`SystemCapabilities`].
    ///
    /// [`SystemCapabilities`]: crate::privilege::SystemCapabilities
    fn is_met_by_capabilities(&self, caps: &crate::privilege::SystemCapabilities) -> bool {
        match self {
            Self::SystemLibrary(_) | Self::ToolInPath(_) => true,
            Self::RawSocketAccess => caps.has_raw_socket_access,
            Self::PrivilegedPort(_) => caps.can_bind_privileged_ports,
            Self::RootAccess => caps.is_root,
            Self::TunDeviceAccess => {
                // TUN device creation requires CAP_NET_ADMIN on Linux, or root on other platforms
                #[cfg(target_os = "linux")]
                {
                    caps.is_root || check_cap_net_admin()
                }
                #[cfg(not(target_os = "linux"))]
                {
                    caps.is_root
                }
            }
            // Capture access, not raw-socket access. These are genuinely separable: a macOS
            // user in the ChmodBPF group owns /dev/bpf* without being root, so they can capture
            // (ARP, DataLink, IS-IS) while `SOCK_RAW` still fails. Checking the raw-socket flag
            // here reported those protocols as unavailable to precisely the users who can run
            // them — the mistake `SystemCapabilities` was split apart to make impossible.
            Self::PromiscuousMode => caps.has_packet_capture_access,
        }
    }

    /// Get installation instructions for this dependency
    pub fn installation_hint(&self) -> String {
        match self {
            Self::SystemLibrary(name) => {
                #[cfg(target_os = "linux")]
                {
                    match *name {
                        "pcap" => "Install with: apt-get install libpcap-dev (Debian/Ubuntu) or yum install libpcap-devel (RHEL/CentOS)".to_string(),
                        "smbclient" => "Install with: apt-get install libsmbclient-dev (Debian/Ubuntu)".to_string(),
                        "git2" => "Install with: apt-get install libgit2-dev (Debian/Ubuntu)".to_string(),
                        _ => format!("Install with: apt-get install lib{}-dev (Debian/Ubuntu)", name),
                    }
                }
                #[cfg(target_os = "macos")]
                {
                    match *name {
                        "pcap" => "Install with: brew install libpcap".to_string(),
                        "smbclient" => "Install with: brew install samba".to_string(),
                        "git2" => "Install with: brew install libgit2".to_string(),
                        _ => format!("Install with: brew install lib{}", name),
                    }
                }
                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
                {
                    format!("Install lib{} for your platform", name)
                }
            }
            Self::ToolInPath(name) => {
                match *name {
                    "protoc" => "Install from: https://github.com/protocolbuffers/protobuf/releases or use package manager".to_string(),
                    _ => format!("Ensure '{}' is installed and in PATH", name),
                }
            }
            Self::RawSocketAccess => {
                #[cfg(target_os = "linux")]
                {
                    "Grant capability with: sudo setcap cap_net_raw+ep /path/to/netget".to_string()
                }
                #[cfg(not(target_os = "linux"))]
                {
                    "Run as root/administrator".to_string()
                }
            }
            Self::PrivilegedPort(port) => {
                format!("Run as root/administrator, or use an unprivileged port (>= 1024) instead of {}", port)
            }
            Self::RootAccess => "Run as root/administrator".to_string(),
            Self::TunDeviceAccess => {
                #[cfg(target_os = "linux")]
                {
                    "Grant capability with: sudo setcap cap_net_admin+ep /path/to/netget".to_string()
                }
                #[cfg(not(target_os = "linux"))]
                {
                    "Run as root/administrator".to_string()
                }
            }
            Self::PromiscuousMode => {
                #[cfg(target_os = "linux")]
                {
                    "Grant capability with: sudo setcap cap_net_raw+ep /path/to/netget".to_string()
                }
                #[cfg(not(target_os = "linux"))]
                {
                    "Run as root/administrator".to_string()
                }
            }
        }
    }
}

/// Ask the dynamic linker whether this process can load a library.
///
/// This is the honest form of the question. A filesystem search answers "is there a
/// file with this name in one of the directories I guessed", which on modern macOS is
/// **always no** for a system library: since Big Sur the system libraries ship only
/// inside the dyld shared cache and `/usr/lib/libpcap.dylib` does not exist on disk,
/// even though `dlopen("libpcap.dylib")` succeeds and every binary on the machine links
/// against it. `dlopen` consults the cache, honours `DYLD_*`/`LD_LIBRARY_PATH`, and
/// resolves whatever the loader would resolve at link time — which is precisely the
/// property a dependency check is trying to establish.
///
/// The handle is released immediately; the point is the answer, not the library.
#[cfg(unix)]
fn dlopen_succeeds(soname: &str) -> bool {
    let Ok(cname) = std::ffi::CString::new(soname) else {
        return false;
    };

    // SAFETY: `cname` is a valid NUL-terminated C string that outlives the call, and
    // the returned handle is closed immediately. `RTLD_LOCAL` keeps the symbols out of
    // the global namespace so a probe cannot change how later symbol lookups resolve.
    unsafe {
        let handle = libc::dlopen(cname.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL);
        if handle.is_null() {
            false
        } else {
            libc::dlclose(handle);
            true
        }
    }
}

/// The names to try with the dynamic linker, most conventional first.
///
/// Linux only carries the unversioned `lib<name>.so` symlink when the `-dev` package is
/// installed, so the versioned sonames are tried too — a runtime-only install is still
/// a library this process can load.
#[cfg(unix)]
fn library_candidates(name: &str) -> Vec<String> {
    #[cfg(target_os = "macos")]
    {
        vec![
            format!("lib{}.dylib", name),
            format!("{}.dylib", name),
            format!("/usr/lib/lib{}.dylib", name),
            format!("/opt/homebrew/lib/lib{}.dylib", name),
            format!("/usr/local/lib/lib{}.dylib", name),
        ]
    }

    #[cfg(not(target_os = "macos"))]
    {
        vec![
            format!("lib{}.so", name),
            format!("lib{}.so.1", name),
            format!("lib{}.so.0", name),
            format!("lib{}.so.2", name),
        ]
    }
}

/// Probe for a system library.
///
/// `dlopen` success is **Available**. When the loader and the platform's own catalogue
/// (`ldconfig -p`, `pkg-config`) both ran and neither knows the library, that is **Missing**. When
/// no catalogue could be run at all, or the platform has no probe, the answer is **Unknown**.
fn system_library_status(name: &str) -> DependencyStatus {
    // Ask the dynamic linker first on every Unix. This is the only probe that is
    // correct on macOS at all (see `dlopen_succeeds`), and on Linux it subsumes the
    // ldconfig search for the common case while also honouring LD_LIBRARY_PATH.
    #[cfg(unix)]
    {
        for candidate in library_candidates(name) {
            if dlopen_succeeds(&candidate) {
                debug!("dlopen({}) succeeded - lib{} is loadable", candidate, name);
                return DependencyStatus::Available;
            }
        }
        debug!(
            "dlopen found no loadable lib{}; falling back to platform probes",
            name
        );
    }

    // Try to use ldconfig to check if library is available
    #[cfg(target_os = "linux")]
    {
        let output = Command::new("ldconfig").arg("-p").output();

        if let Ok(output) = output {
            let stdout = String::from_utf8_lossy(&output.stdout);
            let lib_pattern = format!("lib{}.so", name);
            let found = stdout.contains(&lib_pattern);
            debug!("Checking for lib{}: found={}", name, found);
            return if found {
                DependencyStatus::Available
            } else {
                DependencyStatus::Missing
            };
        }

        // Fallback: try pkg-config
        let pkg_config_output = Command::new("pkg-config")
            .arg("--exists")
            .arg(name)
            .status();

        if let Ok(status) = pkg_config_output {
            let found = status.success();
            debug!("Checking for {} via pkg-config: found={}", name, found);
            return if found {
                DependencyStatus::Available
            } else {
                DependencyStatus::Missing
            };
        }

        debug!(
            "Could not check for lib{} (ldconfig and pkg-config unavailable)",
            name
        );
        DependencyStatus::Unknown(format!(
            "dlopen found no lib{name} and neither ldconfig nor pkg-config could be run"
        ))
    }

    #[cfg(target_os = "macos")]
    {
        // No filesystem search here on purpose. The previous implementation ran
        // `find /usr/lib /usr/local/lib /opt/homebrew/lib -name lib<name>.dylib`, which
        // returns nothing for every library that ships in the dyld shared cache — so
        // libpcap, which is present and links fine on this platform, was reported
        // missing, and any protocol declaring SystemLibrary("pcap") would have been
        // refused at startup on exactly the OS where it works.
        //
        // dlopen above is the real test. pkg-config is kept only as a last resort for
        // a Homebrew-only library whose dylib name does not follow the convention.
        let pkg_config_output = Command::new("pkg-config")
            .arg("--exists")
            .arg(name)
            .status();

        if let Ok(status) = pkg_config_output {
            let found = status.success();
            debug!("Checking for {} via pkg-config: found={}", name, found);
            return if found {
                DependencyStatus::Available
            } else {
                DependencyStatus::Missing
            };
        }

        // dlopen is the loader's own answer; with no pkg-config to consult there is no
        // second opinion, and the loader not finding it under any conventional name is as
        // definite as this platform gets.
        debug!("dlopen and pkg-config found no lib{} on macOS", name);
        DependencyStatus::Missing
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    {
        // No loader query or package catalogue on this platform.
        warn!(
            "Cannot check for lib{} on this platform; not treating it as missing",
            name
        );
        DependencyStatus::Unknown(format!("no library probe for lib{name} on this platform"))
    }
}

/// Probe for an executable on `PATH`, searching it the way `Command::new(tool)` will.
///
/// This walks `PATH` itself rather than running `which`: the question is whether the protocol's
/// own `Command::new(tool)` will find a program, and a `which` that cannot be spawned (no
/// `PATH`, a minimal container) used to read as "the tool is missing". An unset `PATH` is
/// **Unknown**; a `PATH` searched in full without finding an executable `tool` is **Missing**.
fn tool_in_path_status(tool: &str) -> DependencyStatus {
    let Some(path) = std::env::var_os("PATH") else {
        return DependencyStatus::Unknown("PATH is not set, so there is nothing to search".into());
    };

    #[cfg(windows)]
    let names: Vec<String> = ["", ".exe", ".cmd", ".bat", ".com"]
        .iter()
        .map(|ext| format!("{tool}{ext}"))
        .collect();
    #[cfg(not(windows))]
    let names: Vec<String> = vec![tool.to_string()];

    for dir in std::env::split_paths(&path) {
        if dir.as_os_str().is_empty() {
            continue;
        }
        for name in &names {
            let candidate = dir.join(name);
            if is_executable_file(&candidate) {
                debug!("Found tool '{}' at {}", tool, candidate.display());
                return DependencyStatus::Available;
            }
        }
    }
    debug!("Tool '{}' is not on PATH", tool);
    DependencyStatus::Missing
}

/// A regular file (symlinks followed) the current user may execute.
fn is_executable_file(path: &std::path::Path) -> bool {
    let Ok(metadata) = std::fs::metadata(path) else {
        return false;
    };
    if !metadata.is_file() {
        return false;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        metadata.permissions().mode() & 0o111 != 0
    }
    #[cfg(not(unix))]
    {
        true
    }
}

/// The dependency that must stop a start, if any: the first **system library or tool** among
/// `deps` whose probe established it is absent.
///
/// Dependencies derived from `privilege_requirement` are skipped — startup gates privilege
/// itself, with a better message, and checking them here would report one failure twice. A
/// probe that could not answer ([`DependencyStatus::Unknown`]) is logged and let through:
/// dependencies inform, and "we could not tell" must never become a refusal.
pub fn startup_blocker(
    deps: &[ProtocolDependency],
    caps: &crate::privilege::SystemCapabilities,
) -> Option<ProtocolDependency> {
    for dep in deps {
        if !matches!(
            dep,
            ProtocolDependency::SystemLibrary(_) | ProtocolDependency::ToolInPath(_)
        ) {
            continue;
        }
        match dep.status(caps) {
            DependencyStatus::Available => {}
            DependencyStatus::Missing => return Some(dep.clone()),
            DependencyStatus::Unknown(why) => {
                tracing::warn!(
                    "Could not determine whether {} is present ({}); starting anyway",
                    dep.name(),
                    why
                );
            }
        }
    }
    None
}

/// Check for CAP_NET_ADMIN capability on Linux
#[cfg(target_os = "linux")]
fn check_cap_net_admin() -> bool {
    if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
        for line in status.lines() {
            if line.starts_with("CapEff:") {
                if let Some(cap_hex) = line.split_whitespace().nth(1) {
                    if let Ok(caps) = u64::from_str_radix(cap_hex, 16) {
                        // CAP_NET_ADMIN is bit 12 (0x1000)
                        const CAP_NET_ADMIN: u64 = 1 << 12;
                        let has_cap = (caps & CAP_NET_ADMIN) != 0;
                        debug!(
                            "Checked CAP_NET_ADMIN via /proc: caps=0x{:x}, has_cap={}",
                            caps, has_cap
                        );
                        return has_cap;
                    }
                }
            }
        }
    }

    debug!("Could not detect CAP_NET_ADMIN capability");
    false
}