§1
RoamSwitch for Linux is a resident network-security and system-diagnostics application that brings the zero-trust network defense model of macOS RoamSwitch to Linux (systemd + nftables environments).
This edition targets client machines (workstations, laptops, single-board computers) and assumes autonomous behaviour that follows the machine as it moves between networks. A hardened server / headless-infrastructure edition (RoamSwitch Server Edition — always-on default-deny, critical-path FIM integrity, remote webhooks, and static policy enforcement) is delivered as a dedicated server build.
It identifies the connected Wi-Fi / wired network by its gateway MAC address and autonomously switches the active nftables firewall profile. On top of that it provides three layers of man-in-the-middle defense on untrusted networks — a VPN tunnel + kill-switch (WireGuard or Tailscale, the primary defense), a preventive ARP/NDP lock, and ARP spoof detection — plus behavioral ransomware detection (fanotify + Shannon entropy + canaries), a passive link guard that flags or blocks phishing connections (NFQUEUE + SNI), a Port Anomaly Guard, BadUSB mitigation, kernel local privilege escalation (LPE) preemption (Frag Gap CVE-2026-53362 mitigation), eBPF runtime threat sensor (Falco / Tetragon) integration with sub-second process freezing (SIGSTOP) and Air-Gap network isolation, a 24-item security health assessment, and a built-in Model Context Protocol (MCP) server.
This whitepaper describes the architecture, threat model, and design of each defense mechanism — and, in particular, how the principle of “fully local processing; the only thing sent off the machine is a request for signed detection data, carrying nothing about you” is guaranteed at the code level. It avoids marketing language; the claims can be checked against the actual behavior of the distributed package (binary) and the self-check in the appendix. RoamSwitch for Linux is proprietary freeware and its source code is not published (§11).
| Principle | Detail |
|---|---|
| Zero telemetry | The product sends nothing about you or your machine to anyone. There is no telemetry, analytics, crash reporting, usage metrics, remote config, or license-activation server. |
| Receive-only updates | The one outbound request the product makes is a once-a-day GET of a fixed, signed manifest from lafine.net/updates/v1 — the scam-site list for the link guard, the ClamAV signature version, the latest app version, the package-CVE maps (Homebrew/npm/PyPI/crates.io/RubyGems/Packagist/Go/Maven), the active-vulnerability-scan CVE map, and the popular-npm-package-name list used for typosquat detection. It carries no query string, no cookies, no identifying headers, and no information about you. updates.enabled = false turns it off entirely; the product then runs on bundled data with zero external contact. |
| Fully local processing | Every assessment, detection, and audit runs to completion on the local machine. URL safety analysis and secret scanning evaluate their input heuristically without transmitting it anywhere. |
| Least privilege | Privileged operations are confined to a root daemon; the UI, CLI, and MCP server request them over a Unix-domain-socket IPC. The daemon opens no TCP/UDP socket; the link guard uses only the AF_NETLINK NFQUEUE verdict channel (kernel ↔ userspace, no packet egress). |
| Fail closed | Air-Gap (emergency isolation) survives a daemon restart and is re-asserted within seconds if its rules are externally deleted. |
| Offline licensing | Pro / Business licenses are verified purely by Ed25519 signature check. No call to an activation server occurs. |
§2
RoamSwitch for Linux primarily targets the laptop / mobile workstation that roams between untrusted networks.
Attackers in scope
- An attacker on the same LAN — port scans on shared Wi-Fi, ARP / NDP spoofing (including rogue Router Advertisements) for a man-in-the-middle, rogue DHCP, passive sniffing after MAC flooding, unauthorized access to a dev server or local LLM API accidentally bound to
0.0.0.0. - Malicious USB devices (BadUSB) — impersonating a keyboard and injecting keystrokes before the screen locks; unregistered USB mass storage for data exfiltration or to carry in malware.
- Ransomware / fileless malware — entering via a downloaded file and bulk-encrypting the user’s documents.
- DNS-based C2 and phishing — malware using name resolution to reach a C2 server or phishing site.
Out of scope
- An attacker who already holds root; kernel- or firmware-level rootkits.
- Attacks that physically remove the disk (the domain of full-disk encryption; RoamSwitch diagnoses whether encryption is present but does not encrypt).
- AirDrop-style proximity wireless sharing control (no OS-native mechanism on Linux; not supported).
§3
RoamSwitch for Linux is implemented in Rust across seven crates. All communication with the daemon goes through /run/roamswitch/roamswitch.sock (a Unix domain socket). There is no listening TCP/UDP socket in the daemon, the app, or the MCP server. The only thing the app binds is a single Unix socket to prevent a second instance.
# User space (unprivileged) roamswitch-app GTK3 + AppIndicator tray roamswitch (CLI) Thin shell frontend roamswitch-mcp stdio JSON-RPC (launched by AI clients) │ │ AF_UNIX /run/roamswitch/roamswitch.sock (local IPC only) ▼ roamswitch-daemon root / systemd Type=notify + WatchdogSec=30 · nftables profile control (inet roamswitch table) · Sentinel loop (network detection & profile enforcement) · VPN tunnel + kill-switch (WireGuard: wg-quick / roamswitch_vpn, Tailscale: roamswitch_ts) · Preventive ARP/NDP lock (neigh table pin) / detection · Port anomaly guard (roamswitch_dev_guard) · fanotify guard (ransomware / malware) · Link guard (NFQUEUE — inet roamswitch_linkguard) · Canary engine / Quarantine Vault · Kernel LPE hardening (userns disable, Yama LSM) · eBPF runtime integration (/var/run/roamswitch/events.sock) · Millisecond SIGSTOP freeze of attacking processes & Air-Gap tie-in · USB monitoring (evdev EVIOCGRAB — BadUSB) / approval queue · Ed25519 license verification roamswitch-updater A standalone systemd-timer helper. The only crate with an HTTP/TLS stack (daily signed-manifest GET) roamswitch-core Pure logic library (shared by every crate) roamswitchkit MCP client SDK
| Component | Privilege | Role |
|---|---|---|
| roamswitch-daemon | root (systemd, Type=notify + WatchdogSec=30) | All privileged operations. As the single writer, controls nftables, DNS, and systemd units. |
| roamswitch-app | login user | GTK3 GUI + AppIndicator tray. Configuration editing and visualization only; every privileged action goes through IPC. |
| roamswitch (CLI) | login user | Thin shell front-end using the same IPC socket. |
| roamswitch-mcp | process spawned by the AI client | stdio JSON-RPC. Provides read-only diagnostic information. |
All communication with the daemon goes through /run/roamswitch/roamswitch.sock (a directory with 0700-equivalent permissions). User configuration is stored as plaintext JSON at ~/.config/roamswitch/config.json. The daemon reads /home/*/.config/roamswitch/config.json with root privileges (it runs with HOME=/root). Configuration never leaves the machine; there is no account registration and no cloud sync.
§4
The sentinel loop (5-second cycle by default) reads the default gateway’s MAC from /proc/net/arp and ip neigh and matches it against the registered MACs in config.json. MAC is used rather than SSID because an SSID is trivially spoofed. The desktop notification shown when the profile switches states the reason for the switch (“an unregistered network” / “your setting for the registered network X” / “no network connection”).
| Profile | Situation | nftables behavior |
|---|---|---|
| open (trusted) | A registered home or corporate network | policy accept. Local traffic allowed. |
| balanced | Unregistered but configured as relatively safe | Default-deny inbound. Only established connections and lo allowed; exposed-port exposure monitored. |
| lockdown (away protection) | Public Wi-Fi / unregistered network | All inbound packets stealth-dropped. Sharing services auto-stopped (opt-in). |
All rules are isolated in a dedicated nftables table named inet roamswitch and never collide with other firewall configuration (ufw / firewalld). Manual-override expiry (1 hour / 2 hours / 4 hours / until midnight / until cleared / until next disconnect) is enforced by the daemon as the single writer. The GUI never writes an expiry; it re-reads the config.json the daemon rewrote on the next tick and reflects it in the UI (avoiding a conflict).
Air-Gap (emergency network isolation)
A total-isolation mode that sets policy drop (priority -100) on both the input and output hooks of the inet roamswitch table, permitting only lo.
- Fail closed — on daemon restart, detecting the marker file (
/run/roamswitch/airgap.active) re-asserts isolation. - Self-healing — if an external process deletes the rules with
nft delete table, the sentinel loop re-applies within seconds. - Automatic expiry — 600 seconds after activation (identical to the macOS
maxAirGapDuration) the isolation lifts automatically, preventing a permanent loss of connectivity from a misclick.
table inet roamswitch {
chain input {
type filter hook input priority -100; policy <accept|drop>;
iif "lo" accept
ct state established,related accept
# balanced/lockdown: drop everything else
}
chain output {
type filter hook output priority -100; policy <accept|drop>;
# policy drop only during Air-Gap; only lo and ct established are allowed
}
}
Passive link guard (phishing-connection detection)
The daemon watches where outbound connections are going and flags — or, for the narrow “clearly phishing” set, blocks — connections to dangerous hosts. It replaces the older paste-a-URL checker, which required the user to act.
- Interception — a dedicated nftables table
inet roamswitch_linkguardhooks theoutputpath and queues DNS questions and new/early web connections to a userspace worker via NFQUEUE. NFQUEUE is a kernel↔userspace verdict channel overAF_NETLINK; the daemon binds no socket and originates no packet. The queue rules carrybypass, so if the worker is not running, traffic passes unimpeded (Air-Gap remains the separate hard stop). - Hostname sources — the DNS question name, the TLS ClientHello SNI (so a browser using DoH is still covered by hostname), and the plaintext HTTP
Hostheader. Encrypted ClientHello (ECH), once widely deployed, is a known blind spot. - Verdict (
roamswitch-core, pure logic) — a host is checked against the local phishing/threat feed and a set of offline heuristics: IDN/confusable homograph of a protected brand, brand token in a non-official domain, raw-IP host, high-risk TLD.
Mode (linkGuard.mode) | Feed hit or brand homograph | Other suspicious signals |
|---|---|---|
off | not inspected | not inspected |
warn | connection held up to 8 s + an allow/block approval prompt | notification only |
block (default) | connection dropped + an allow dialog (once per host) | notification only |
Only a feed hit or a brand-homograph host is ever hard-blocked; everything the heuristics alone flag is a warning. warn mode holds the connection to a dangerous host for up to 8 seconds while it asks the user; if the hold expires with no answer it is fail-closed (the packets are dropped and the verdict is not cached) — not answering “is this site OK?” must not mean “yes”, and the next attempt prompts again. block mode shows one notification and one allow dialog per host (a browser opening many connections to the same blocked host no longer floods notifications). On a dangerous approval dialog the default button is “Keep blocking” and “Allow” is styled as the quiet, non-default choice. A blocked host can be allowed permanently or for five minutes from the notification (linkGuard.allowlist).
DNS is inspected, never forged: a blocklisted DNS question produces a notification only; enforcement happens on the subsequent TCP connection, so RoamSwitch never injects a synthetic DNS response. The feed is a compiled list of phishing/scam domains, shipped in the package and refreshed by the daily updater (§11). With no feed present the offline heuristics still run. The feed file’s Ed25519 signature is checked before load; a bad or missing feed fails to “heuristics only”, never to “allow all”.
VPN tunnel + kill-switch (the primary MITM defense)
Anti-MITM protection that does not depend on the integrity of layer 2. On an untrusted network, an always-on encrypted tunnel to a trusted endpoint makes local ARP/NDP spoofing, rogue DHCP and passive sniffing irrelevant — the attacker sees only ciphertext. The ARP/NDP lock and the detection below are secondary to this feature.
Two backends to choose from — “WireGuard (config file)” or “Tailscale (exit node)”. RoamSwitch implements no cryptography of its own and is not a VPN provider. Both backends are optional dependencies; if absent the GUI shows the install command.
(A) WireGuard backend
- No VPN server is provided — uses a WireGuard config the user supplies: a
.conffrom a provider such as Mullvad, IVPN or Proton VPN, or the user’s own WireGuard server (a VPS or a home server). No default config is shipped. - Config — the daemon validates the
.confand stores it at/etc/wireguard/roamswitch.conf(0600, interfaceroamswitch). It cannot live elsewhere: the AppArmorwg-quickprofile on Ubuntu 24.04+ / Debian 13 denieswg-quickreading a.confoutside/etc/wireguard/— even as root. A split-tunnel config (AllowedIPsnot0.0.0.0/0/::/0) is flagged as “not all traffic is protected”. - Kill-switch — a dedicated nftables table
inet roamswitch_vpn(policy drop, both hooks) drops everything except loopback, the tunnel interface (roamswitch, matched byoifname), UDP to the pinned endpoint IP, DHCP, and ICMP. It is installed before the tunnel is brought up (endpoint resolved to fixed IPs viagetent ahostsfirst), so there is no leak window. Ifwg-quick upfails, the kill-switch stays up.
(B) Tailscale backend
- For users who already run Tailscale. RoamSwitch does not run
tailscale up/ login / installtailscaled— it readstailscale status --jsonand runstailscale set --exit-node=<node>/--exit-node=(clear). - An exit node is required — anti-MITM protection only holds with an exit node that routes all traffic. With none configured, protection is not armed and the UI warns “not protected”. If the chosen node goes offline, protection is disarmed automatically and the user notified (routing everything through a dead node would black-hole the connection).
- Kill-switch (
inet roamswitch_ts,policy drop) — Tailscale’s transport cannot be pinned to a single endpoint IP (DERP relays + roaming peers), so this is looser than the WireGuard one. It permits: loopback, the tunnel interface (tailscale0), the CGNAT ranges100.64.0.0/10andfd7a:115c:a1e0::/48, STUN (udp/3478), DERP (tcp/443),udp/41641, DHCP, ICMP, MagicDNS (100.100.100.100:53), established connections. Everything else — arbitrary UDP, DNS to a local resolver, SMB, mDNS, plaintext HTTP, arbitrary TCP — is dropped. - Kill-switch caveats — (1) broader allow-list than the WireGuard backend (
udp/3478,tcp/443, the whole CGNAT/10). (2) An on-path attacker still sees metadata (that Tailscale is in use, DERP region, timing) and can drop/delay — but cannot read the tunnelled payload. (3) When direct UDP is blocked, confidentiality then rests on Tailscale’s own pinned-cert DERP TLS. (4) With MagicDNS off + a local resolver, DNS breaks while armed (UI warning). (5) Captive portals must be logged into before enabling.
Common — armed only when vpn_on_untrusted_enabled and the network is untrusted (level != open). On a trusted network the exit node is cleared and the kill-switch removed (never tailscale down). Off by default, opt-in. The WireGuard tunnel / Tailscale transport goes to the user’s own VPN endpoint / tailnet and is unrelated to lafine’s infrastructure (§9).
Preventive ARP/NDP lock
The moment an untrusted network is joined, the MAC addresses of the on-link infrastructure a MITM attacker would need to impersonate are pinned into the kernel neighbour table as PERMANENT (static) entries. Once pinned, the kernel ignores spoofed ARP replies / neighbour advertisements.
- What is pinned — (1) the IPv4 default gateway, (2) the IPv6 default router (often a link-local
fe80::address), (3) any on-link DNS resolver (a public resolver is reached via the gateway and is skipped). IPv6 neighbours are pinned withip -6 neigh replace ... nud permanent. - Trusted networks are never pinned — so a home-router reboot can’t black-hole the user. Re-pinned on every network switch (TOFU); a legitimate failover is picked up on the next reconnect. Pinned entries are recorded in
/var/lib/roamswitch/arp_lock.json. - On by default (Community Edition policy; same breakdown as the macOS Pro build). Toggle:
gateway_arp_lock_enabled.
ARP spoof detection (notify-first)
Monitors /proc/net/arp for one IP mapping to multiple MACs and for a suspicious sudden change of the gateway MAC. In lockdown: cut all traffic (Air-Gap) + notify. On balanced / trusted networks: notify without cutting; the user triggers the emergency Air-Gap from RoamSwitch. This (a) stops an attacker from weaponising a single spoofed ARP packet into a self-inflicted outage, and (b) stops a router reboot or access-point roam from triggering it by mistake. Toggle: arp_spoof_guard_enabled.
Port Anomaly Guard
Automatically contains a listening port newly exposed to the external LAN (a backdoor, C2, or a dev server / local LLM API accidentally bound to 0.0.0.0). The verdict is baseline-relative, not “dangerous port number”. On enable, the currently-exposed executables are learned as “known”; thereafter only a newly appeared exposed executable is flagged (identity = the executable path, snaps normalised to snap:<name>). System daemons are out of scope. Containment is via the inet roamswitch_dev_guard table: drop TCP to that port from anything but lo — the machine itself keeps using it. Upon auto-containment, an immediate desktop notification and confirmation dialog (“Allow This Port” / “Keep Blocked”) is presented, allowing one-click permanent whitelisting. Any port can also be allowed individually, and the baseline recaptured, from the port-diagnostics screen. On by default. Toggle: port_anomaly_guard_enabled. Ported from macOS.
Docker Risk Detection & Firewall Bypass Protection
Docker's published ports (-p) are known to bypass the host's standard firewall, so inspection rules are permanently inserted into the DOCKER-USER chain, blocking any external traffic that isn't explicitly permitted (no configuration needed, on by default). In addition, docker events is watched in real time to detect the instant a --privileged container starts or /var/run/docker.sock is bind-mounted — a container-escape risk — and notifies you immediately. This flags a risky configuration, not confirmed compromise, so it is notify-only with no automatic blocking.
Secret & API Key Leak Scanner
Detects API keys (OpenAI, Anthropic, AWS, GitHub, Slack, Stripe, and more) and SSH private keys using Shannon entropy plus pattern matching. roamswitch audit-secrets <text|file|directory> scans text and files, plus an entire directory recursively (skipping .git, node_modules, and similar; files over 2MB or that look binary are skipped). The same capability is also available via the audit_secrets MCP tool and the audit_secrets_directory() Python SDK method. Content is never transmitted anywhere.
First-run setup wizard and confirmation UI
The package ships /etc/xdg/autostart/roamswitch.desktop, so the setup wizard launches on the first login after install (afterwards it is tray-resident only). Enabling / disabling a security guard shows a confirmation dialog for the same guards as the macOS build (Port Anomaly, ARP auto-containment, USB storage, BadUSB keyboard, Bluetooth). Event-time decisions (an unknown USB keyboard, an unregistered USB drive, an unknown port auto-blocked, an emergency Air-Gap) are presented as modal dialogs.
§5
Shannon-entropy burst detection · YARA scanning
Write events are tracked per PID. If a single process creates 20 or more distinct files with entropy ≥ 7.92 within 5 seconds, it is treated as ransomware bulk-encryption: the process is frozen with SIGSTOP and Air-Gap is triggered.
- Complete separation of YARA scanning and entropy tracking — YARA malware inspection is applied to all written files without exception (zero bypass, scanning
~/.cache/,node_modules/,/tmp/, and archives). Meanwhile, ransomware entropy burst analysis excludes naturally compressed formats (.zip,.tgz,.tar.gz,.gz,.png,.jpg,.mp4,.wasm) and verified package managers (npm,npx,cargo,rustc,dpkg,apt,tar) operating in build/cache trees to eliminate false-positive SIGSTOP freezes. - Repeated overwrites of the same file (e.g.
shred’s three passes) count as one event. - Critical-process protection — PIDs are checked against a hard-coded NEVER_FREEZE list (
systemd,dockerd,NetworkManager,sshd,gnome-shell, …) and are never frozen, preventing a machine-wide hang.
Canaries (decoy files)
Decoy files are placed in the user’s Documents, Desktop, Downloads, and Pictures folders and a dedicated directory, and compared against a SHA-256 baseline on a 3-second cycle. Any rename, deletion, truncation, or content change triggers Air-Gap immediately. Because the daemon runs as root, it enumerates /home/* and deploys canaries into every real user’s directories.
fanotify monitoring · Quarantine Vault · ClamAV
- The daemon monitors
/home,/var/tmp,/dev/shmat the mount level and/at the filesystem level viaFAN_CLASS_CONTENT(fallbackFAN_CLASS_NOTIF). - Notify-first, no silent quarantine. A bare YARA / ClamAV hit is never silently quarantined or
FAN_DENY’d. A real signature raises a modal request in/run/roamswitch/approvals.json(Quarantine / Allow / Later); nothing enters the vault until the user confirms. Only an actualexecveisFAN_DENY’d, and only whenpre_exec_blocking_enabledis on. The approval times out fail-open. - Test signatures such as EICAR are history-only — the industry-standard test string raises no notification, only an entry in notification history, and is never quarantined or blocked, in both the fanotify guard and the download guard.
- A user-configured
scan_exclusionspath list (absolute paths, applied recursively; it subsumes the legacyclamav_exclusions) is honoured by both the YARA and ClamAV scanners. “Allow” on the prompt and “Restore” in the Quarantine tab both append to it, so the same file is not re-flagged. - Detected and confirmed real malware is relocated to
~/.local/share/roamswitch/quarantine/. The vault is0700; each sample is0400(read-only, non-executable). On restore, the file returns to0644and is chowned to the destination directory’s owner. - The permission-event loop does zero filesystem I/O — config is read by a dedicated background thread into a
Mutex<GuardConfig>snapshot; hot-path accessors just lock and copy. This is a design invariant: blocking onFAN_OPEN_PERMwould freeze the machine. - When ClamAV is present, newly downloaded executables (
.sh,.deb,.elf, …) and dangerous AI models (.pkl,.pt) are scanned against the local DB. ClamAV is an optional dependency; the other defenses work without it. When it is installed, the daily updater (§11) invokesfreshclamso the signature database stays current.
Kernel Local Privilege Escalation (LPE) Preemption & Hardening
When an attacker or malware gains an unprivileged user foothold, defense-in-depth prevents exploiting Linux kernel vulnerabilities to obtain root privileges.
- Disabling Unprivileged User Namespaces (Frag Gap CVE-2026-53362 Mitigation) — Many modern Linux kernel LPE vulnerabilities rely on unprivileged users calling
unshare(CLONE_NEWUSER)to create isolated user namespaces, subsequently accessing uninitialized kernel structs or legacy network subsystems with elevated namespace capabilities. RoamSwitch assesses and enforcesuser.max_user_namespaces = 0(orkernel.unprivileged_userns_clone = 0) via one-click hardening or setup wizard. This closes off the attack surface from unprivileged processes even with unpatched kernel zero-days. - Yama LSM Memory Protection — Enforcing
kernel.yama.ptrace_scope = 1(or2) prevents unauthorized processes from attaching viaptraceor reading memory via/proc/$pid/mem, safeguarding browser sessions and GPG/SSH private keys. - Core Dump Suppression — Setting
fs.suid_dumpable = 0prevents crashing sensitive processes from writing plaintext memory dumps to disk. /tmp&/dev/shmnoexecMount Defense — To prevent payload drop-and-execute attacks, RoamSwitch remounts/tmpand/dev/shmwithnoexec,nosuid,nodevwhen connecting to untrusted networks.
eBPF Runtime Threat Sensor & Autonomous Process Freezing (Desktop eBPF Guard)
The RoamSwitch client daemon includes native integration with in-kernel modern eBPF sensors (Falco / Tetragon / BTF).
# eBPF runtime threat detection & autonomous interception flow [Malicious process (LPE/malware)] │ Illegal syscall execution (Frag Gap exploitation attempt) ▼ [Linux kernel (eBPF probe)] │ eBPF ring buffer (2MB compact design) ▼ [Falco (RoamSwitch Optimized)] │ Direct in-memory UNIX socket (/var/run/roamswitch/events.sock) ▼ [roamswitch-daemon (root)] ├─► ① Millisecond freeze of the attacking process: kill(pid, SIGSTOP) [state: Ts] ├─► ② Full network cutoff: nftables input/output drop [Air-Gap engaged] └─► ③ Desktop emergency alert: GTK3 modal & libnotify notification
- Direct UNIX Domain Socket IPC (
/var/run/roamswitch/events.sock) — Alerts from Falco or other eBPF sensors are received directly over an in-memory UNIX socket without writing to disk logs, eliminating disk I/O latency and enabling sub-second response handoffs. - Autonomous Multi-Layer Mitigation (Process Freezing & Air-Gap) — When an event with priority
CriticalorEmergency(such as kernel LPE, namespace escape, or ransomware execution) is received, the daemon executesSIGSTOPon the offending PID to immediately pause process execution (state:Ts), deployspolicy dropacrossinet roamswitch(Air-Gap), and triggers an immediate desktop notification. - Low-overhead desktop tuning & log-bloat prevention (RoamSwitch Optimized) — to curb battery drain, CPU load, and disk exhaustion on laptops and workstations, an optimized profile (
99-roamswitch-optimized.yaml) ships bundled:- Severity Filtering (
priority: warning) — Filters out 95% of routine Notice/Info events. - Kernel Early Drop (
drop_failed_exit: true) — Discards failed syscall exits directly in the kernel driver, halving context switches and CPU load. - Compact Ring Buffer (
cpus_for_each_buffer: 2) — Reduces ring buffer size to 2MB, maintaining resident RAM at 30–50MB and CPU usage <1%. - Mandatory Log Rotation Cap (
/etc/logrotate.d/roamswitch-falco) — Enforces a strict 50MB ceiling with 7-generation compressed rotation.
- Severity Filtering (
- Hybrid Resilience (Standalone vs. eBPF Mode) — Without Falco, RoamSwitch protects the system via kernel sysctl (
user.max_user_namespaces = 0), nftables roaming profiles, fanotify entropy tracking, and canaries. With Falco, deep in-kernel behavioral telemetry intercepts advanced zero-day exploits and freezes attacker processes in real time.
§6
USB events are monitored; keyboard-class (HID) devices and USB mass storage are tracked. Off by default (shipping isEnabled = false, matching the macOS USBKeyboardGuard / USBStorageGuard). Opt in from the first-run wizard or the USB tab; enabling shows a confirmation dialog.
Rogue USB keyboard (keystroke injection)
- No kernel-level physical port de-authorization (important). The old implementation (≤1.0.6) wrote
0to/sys/bus/usb/devices/*/authorizedfor an unregistered keyboard, physically disconnecting it. On a combined keyboard+mouse receiver (common on a Raspberry Pi or mini PC) this also disconnects the mouse, locking out a machine with no built-in input. RoamSwitch now hard-refuses writingauthorized=0to any device exposing a HID interface (bInterfaceClass 03), and the daemon re-authorizes anyauthorized==0HID device at startup to self-heal machines a previous version bricked. - Keystrokes are dropped in software. On detecting an unregistered keyboard, an exclusive evdev
EVIOCGRABis taken on that device’s/dev/input/eventN(only nodes with akbdhandler in/proc/bus/input/devices, matching VID:PID). The mouse node is untouched. This is the Linux equivalent of the macOSCGEventTap; the device stays powered. - Safety — the grab is only taken if there is another keyboard to type on (a built-in one, or an approved-and-connected USB one), so a machine with a single new keyboard is never locked out.
- Approval dialog — alongside the grab, an approval request is queued in
/run/roamswitch/approvals.jsonand the GUI shows a modal (Allow / Deny). Allow whitelists the device and releases the grab. Fail-open after 3 minutes.
Rogue USB mass storage
When an unregistered USB mass-storage device (no HID interface) is inserted, the guard is enabled, and the network is untrusted / in lockdown, it is held with authorized=0 (safe for a non-HID device) and an approval dialog is shown. Allow re-authorizes and whitelists; Deny keeps it held. The macOS build was aligned to this “hold read-only + approval prompt” model in 1.7.4.
Identification and grandfathering
HID is determined from bInterfaceClass == "03" and the Handlers= line in /proc/bus/input/devices. A combo receiver (one VID:PID with both a kbd and a mouse section) is merged by VID:PID. An unregistered keyboard present at daemon startup is grandfathered; only a keyboard hot-plugged afterwards is grabbed.
§7
The LinuxHealthChecker in roamswitch-core evaluates 24 items and produces a 0–100 score, an A–F grade, and per-item advice. From the GTK dashboard, the CLI (roamswitch status), and MCP (get_security_report), the title, detail, and recommendation of all 24 items appear in the user’s language (10 languages).
| Category | Example items |
|---|---|
| Disk / boot | LUKS / dm-crypt encryption, UEFI Secure Boot |
| Access control | LSM (AppArmor / SELinux), SSH / sudo config audit |
| Kernel hardening | sysctl hardening, unprivileged user namespaces disablement (Frag Gap mitigation), core-dump control, noexec on /tmp and /dev/shm |
| Updates | automatic security update configuration |
| Network | ARP spoofing monitoring, gateway ARP/NDP lock state, exposed-port audit, Wi-Fi encryption strength |
| Malware | ClamAV / fanotify (also checks the guard is actually running) / entropy monitoring / noexec |
| Browser | Firefox / Chrome / Chromium Safe Browsing settings |
| DNS / USB | threat-protection DNS, zero-trust state of the USB bus |
Every check is based on reading local files and command output (getenforce, mokutil, resolvectl, ss, …). There is no external query of any kind.
Trust-aware grading. Hardening that RoamSwitch deliberately does not apply on a trusted (home) network — pinning the gateway MAC as PERMANENT (which would black-hole the LAN on a router reboot), remounting /tmp and /dev/shm noexec (which breaks package builds and some installers), and threat-protection DNS under the default scope — is shown green with an explanation rather than a red “not hardened”, and notes that it is applied automatically on an untrusted network. Runtime truth comes from /run/roamswitch/state.json (active_level / network_trusted / fanotify_ready), written every cycle by the daemon. The fanotify item checks that the guard is actually marked and running (/run/roamswitch/fanotify.ready), not merely that the kernel supports it. The exposed-ports item reads the daemon’s live profile (the MCP hard-code is gone).
Per-item hardening. Failed items that have an automated fix (kernel sysctl / Yama / core dumps, /tmp noexec, USB zero-trust, gateway ARP pinning, threat-protection DNS, restarting the daemon when the fanotify guard is down) carry a “🔧 Harden” button in the GTK dashboard. The daemon runs as root, so no pkexec is needed except for the daemon restart.
§8
roamswitch-mcp speaks JSON-RPC over stdio and is spawned by AI clients such as Claude Desktop, Cursor, and Antigravity. Every tool is read-only and fully local.
| Tool | Purpose |
|---|---|
| get_security_report | 24-item assessment, score, recommendations |
| get_exposed_ports | exposed ports and their firewall block status |
| get_guard_status | current protection level and defense-module state |
| audit_url_safety | heuristic URL safety analysis (does not fetch the target) |
| audit_secrets | detection of API keys / secrets in text (does not transmit the text) |
| audit_security_logs | aggregation of local journald / auth logs |
| get_quarantine_status / get_canary_status | quarantine and canary state |
| get_app_help | search of the built-in knowledge base |
| run_active_vuln_scan | Non-destructive, 127.0.0.1-only active vulnerability verification. The only tool that uses the network; off by default and requires opting in from Settings. |
| run_package_cve_scan | Checks installed packages (Mac: Homebrew / Linux: dpkg, dnf, zypper, pacman) against a local CVE map. Sends no network traffic at all. |
| run_package_cve_scan_languages | Checks dependency lockfiles for npm, PyPI, crates.io, RubyGems, Packagist, Go, and Maven against the same local CVE map. Sends no network traffic at all. |
| verify_fim | Re-hashes about 150 critical system files and compares them against the stored baseline to verify integrity. |
| get_file_scan_guard_status | Returns the File Scan Guard's (ClamAV) configuration and the state of the quarantine vault it feeds. |
| get_port_anomaly_incidents | Returns the Port Anomaly Guard's baseline state, currently auto-blocked ports, and up to the 50 most recent incidents. The response explicitly notes that the currently-blocked ports are a present-tense snapshot with no timestamp, distinct from the timestamped incident history. |
| get_ebpf_incidents | Returns the eBPF Runtime Guard's current containment status and the incident history behind it. |
| get_resource_guard_incidents | Returns the Resource Exhaustion / Process Anomaly Guard's history (memory leaks, crash loops) with a confidence tier. |
| get_notification_history | Returns the history of notifications RoamSwitch has sent (security log-audit anomalies, ClickFix detections, and the like) from the past 7 days, most recent first. |
| get_incident_timeline | Correlates every guard's detections into one chronological timeline, with process ancestry and MITRE ATT&CK tags (experimental). |
The MCP server opens no network of its own; it either connects to the daemon’s Unix socket via roamswitchkit or calls roamswitch-core logic directly. The one exception is run_active_vuln_scan — off by default — which sends non-destructive probes to 127.0.0.1, this host itself.
A misattribution found — and fixed — during a local-LLM fire drill
In September 2026, while running a fire drill that had a local LLM (Qwen 3.8 27B) investigate an incident through this MCP server, we found a case where the model misread the data returned by get_port_anomaly_incidents and folded an unrelated, older port block into the narrative of an ongoing incident. The cause: a present-tense snapshot of currently-blocked ports, carrying no timestamp, was returned in the same response as the real, timestamped incident history, with nothing distinguishing the two. The fix adds explicit note fields marking which field is a timeline and which is a snapshot. Re-running the same scenario against the same model afterward, it quoted the note's wording and explicitly declined to attribute the old port to the current incident. Verified both by an automated regression test and by re-running the real local LLM.
§9
RoamSwitch for Linux makes exactly one kind of outbound request: the daily updater’s GET of a signed manifest (§11). Everything else — every assessment, detection, and audit — completes locally. RoamSwitch for Linux is proprietary freeware and its source is not published, so the inspections below were performed by lafine; the results are in 9.2, and the steps in Appendix C are reproducible from the distributed package alone.
9.1 Audit method
- Crate isolation — an HTTP/TLS stack is permitted in one crate only,
roamswitch-updater, which builds the standalone timer-driven updater binary.scripts/audit_no_network.sh(run in CI, gating) fails the build ifroamswitch-core,roamswitch-daemon,roamswitch-app,roamswitch-mcp, orroamswitchkitpulls inreqwest/hyper/ureq/rustls/native-tls/openssl, or if any.rsfile outsideroamswitch-updaterusesTcpStream/TcpListener/UdpSocket/to_socket_addrs/lookup_host. - Dependency tree inspection — the full
Cargo.lockwas searched for telemetry / analytics / crash-reporting SDKs (sentry,opentelemetry, …). roamswitch-updaterreview — its single request target, the absence of a query string / cookies / identifying headers, and its Ed25519 verification of everything it downloads.- Subprocess enumeration — every
Command::new(...)call site was reviewed.
9.2 Results
| Audit item | Result |
|---|---|
| HTTP / TLS stack | Present in roamswitch-updater only (ureq + rustls). Not in core / daemon / app / mcp / kit |
| Telemetry / analytics / crash-reporting SDK | Not present anywhere |
TcpStream / TcpListener / UdpSocket | Not a single occurrence outside roamswitch-updater |
| Socket APIs in use | AF_UNIX (local IPC); AF_NETLINK (NFQUEUE verdict channel, nftables and ip neigh control — no egress); AF_INET only in roamswitch-updater. The VPN tunnel (§4), when enabled, is wg-quick (WireGuard backend) or tailscaled (Tailscale backend) — a subprocess, not the daemon — sending traffic to the user’s own endpoint / tailnet |
| Daily updater | One GET https://lafine.net/updates/v1/manifest per ~24 h. No query string, no cookies, generic User-Agent, no identifiers. All payloads Ed25519-verified. updates.enabled = false disables it entirely |
| VPN tunnel (§4) | Off by default. When enabled, wg-quick builds a WireGuard tunnel to the endpoint in the user’s own .conf — unrelated to lafine. Endpoint hostname resolution via getent ahosts (glibc NSS), not a Rust socket API |
| License activation | Offline Ed25519 signature verification. No communication with an activation server |
RoamSwitch for Linux initiates exactly one kind of outbound request — a daily, receive-only download of signed detection data that carries no information about the user or the machine. Nothing else in the product ever contacts an external server.
9.3 Complete list of externally-facing operations
Every operation that could cause a packet to leave the local machine, directly or indirectly:
| # | Operation | Destination | Trigger | What is sent |
|---|---|---|---|---|
| 1 | Daily updater — GET /v1/manifest, and the feed file when its version changed | lafine.net/updates/v1 (CDN) | systemd timer, once per ~24 h with a randomized delay. Off when updates.enabled = false or roamswitch-update.timer is disabled | a plain HTTPS GET: no query string, no cookies, generic User-Agent, nothing about the user or the machine. Receives a signed manifest + feed |
| 1a | freshclam (ClamAV’s own updater), invoked by the daily updater when ClamAV is installed | the ClamAV project’s CDN | as part of #1 | traffic of ClamAV itself (a separate project); RoamSwitch only launches it |
| 2 | ping -c1 -W1 <gateway IP> | the LAN default gateway (link-local) | to refresh the ARP cache during ARP-spoofing detection | ICMP echo request only. No DNS resolution, no payload |
| 3 | launching the system browser (xdg-open) | https://lafine.net/... (product site) | only when the user clicks a button such as “Open release notes” | the RoamSwitch process opens no socket; it hands the URL to the OS browser |
| 4 | resolvectl dns <iface> … | — (no communication occurs) | threat-protection DNS on an untrusted network (opt-in). <iface> is every physical NIC carrying a default route (wired + wireless at once; lo / tailscale* / wg* / docker* / veth* excluded); also applied on a trusted network when dns_scope = always_on; reconciled every sentinel cycle | it only changes the OS resolver setting; RoamSwitch itself sends no DNS query |
| 5 | package-manager updates | the distro’s apt / dnf / zypper repositories | when the user runs apt upgrade, etc., or clicks the GUI “Upgrade now” button (which runs apt-get / dnf / zypper via pkexec) | RoamSwitch reads local apt-cache policy output and launches the package manager; unrelated to lafine |
| 6 | VPN tunnel (§4) — WireGuard backend: the tunnel wg-quick builds / Tailscale backend: tailscale set --exit-node routes all traffic through the exit node | the user’s own VPN endpoint / tailnet (WireGuard: the endpoint in the .conf; Tailscale: the chosen exit node and DERP relays) | when vpn_on_untrusted_enabled and the network is untrusted. Off by default | encrypted WireGuard / Tailscale traffic; the destination is the user’s own VPN server / tailnet, unrelated to lafine. WireGuard endpoint resolution via getent ahosts (glibc NSS); Tailscale is driven by tailscaled (a separate process) |
Every other RoamSwitch feature — nftables control, ARP/NDP pinning of the neighbour table (ip neigh, no packet egress), the link guard (NFQUEUE, AF_NETLINK only), port scanning (parsing ss output), ARP monitoring, health assessment, entropy monitoring, canaries, fanotify, USB monitoring (evdev grab), quarantine, log auditing, URL and secret auditing — completes using only local file reads and analysis of subprocess output. The §4 VPN tunnel is the only “outbound communication the user explicitly configured”, and its destination is the user’s own VPN server — no data flows to lafine’s infrastructure.
§10
- No account — no user registration, no login, no email address ever requested.
- Configuration is local only — stored in plaintext at
~/.config/roamswitch/config.jsonand never leaves the machine. There is no cloud sync. - Assessment results are local only — security reports and log-audit results are only displayed on screen and, if the user chooses, exported to a local file.
- License keys are verified offline — Pro / Business license tokens contain an Ed25519 signature and are verified with a public key. No query is made to a lafine server.
- No crash reporting — on panic, no stack trace is sent anywhere.
- The daily updater sends nothing about you — no query string, no cookies, no device or install identifier, a generic
User-Agent. It is a download, not a report. Link-guard verdicts, blocked hosts, and scan results are written to the local activity view / journald only, never transmitted.
§11
| Edition | For | Price | License |
|---|---|---|---|
| Community Edition | Linux personal use | Free | Proprietary freeware (bundled EULA) |
| Business (planned) | Fleet management, policy distribution, signed internal apt, SLA for organizations | Paid | Commercial license. Same binary + tier gate |
Purchasers of macOS RoamSwitch Pro Lifetime receive Business features free on their own Linux machines.
Distribution and supply chain
| Channel | Detail |
|---|---|
| APT | https://lafine.net/apt (stable main). Release signed with an RSA-4096 key |
| RPM | https://lafine.net/rpm (separate Fedora / openSUSE). repomd.xml GPG-signed |
| AUR | roamswitch-bin (fetches the GitHub Releases tarball, verifies sha256) |
| tarball | amd64 / aarch64 .tar.gz + .sha256 on GitHub Releases |
- Pinned glibc floor — release binaries are built inside a Debian 12 (bookworm, glibc 2.36) container. This prevents symbol-version mismatches on Debian 12 / Ubuntu 22.04 and later. A “verify glibc floor” CI step fails the build if a dependency requiring more than 2.36 is introduced.
- Signing key management — the private key for apt/rpm repository signing exists only in a GitHub Actions encrypted secret; only the public key is committed to the repository.
11.3 The daily updater
roamswitch-updater is a standalone binary run by a systemd timer (roamswitch-update.timer, OnCalendar=daily with a randomized 3-hour delay and Persistent=true). It is the only component that reaches the network. Each run:
GET https://lafine.net/updates/v1/manifestand its detached signature.- Verify the manifest’s Ed25519 signature with a dedicated threat-feed signing key — separate from the Sparkle app-update key. The feed is re-signed daily by server-side CI, so that private key lives in a CI secret; scoping it to the feed means a leak only lets an attacker serve a bad blocklist (sinkhole / warn — no code path, no app-update path). The app-update signing key never leaves the macOS keychain. A manifest that does not verify is discarded — the updater never falls back to unsigned data.
- If the threat-feed version changed, download the feed, verify its SHA-256 and Ed25519 signature, and install it atomically for the link guard (§4).
- If ClamAV is installed, invoke
freshclam. - Record the latest app version for a notification (the updater never installs the app; on Linux the user runs
apt upgrade).
Failure of any step — offline, a bad signature, freshclam missing — is recorded as updates.lastError and the process still exits 0, so a timer run never generates a systemd failure notification. The whole mechanism is disabled by updates.enabled = false in updates.json or by systemctl disable --now roamswitch-update.timer; the product then runs on the data bundled in the package. The threat feed is also shipped inside the package, so a fresh install has a working feed before the updater has ever run, and apt upgrade refreshes it even with the updater disabled.
§12
- OS: Ubuntu 22.04 / 24.04 and later, Debian 12 and later, Linux Mint / Pop!_OS / elementary / Zorin, Raspberry Pi OS 64-bit (Bookworm), Ubuntu for Raspberry Pi
- Architecture: x86_64 / aarch64
- Required:
systemd,nftables,iproute2(ip neigh— the ARP/NDP lock) (pulled in automatically by the.deb/.rpmdependencies) - Optional:
clamav(malware scanning),nmcli(Wi-Fi encryption detection),libnotify(desktop notifications),wireguard-tools(the WireGuard backend of the VPN tunnel + kill-switch, §4),tailscale(its Tailscale backend, §4),polkit/pkexec(running an upgrade from the GUI) - Not supported: non-systemd distros (Alpine / Void / Devuan)
- Display: 1280×720 or larger (the window and font auto-scale to the screen size, supporting small Raspberry Pi displays)
Appendix A
| Crate | Purpose | Network |
|---|---|---|
| serde / serde_json | serialization of config and IPC | none |
| tokio | async runtime (features = ["full"], but only Unix sockets are used) | none (no TCP) |
| tracing / tracing-subscriber | logging (local / journald) | none |
| thiserror / anyhow | error handling | none |
| regex | string parsing (diagnostics, CSS scaling) | none |
| url | URL parsing (audit_url_safety heuristics) | none (does not fetch) |
| chrono | date/time handling | none |
| ed25519-dalek | offline verification of license signatures | none |
| base64 / sha2 | license tokens, canary hashes | none |
| libc | chown / kill / syscalls | none |
| idna | decoding IDN/punycode hosts for homograph detection | none |
| nfq (daemon only) | NFQUEUE bindings for the link guard — pure Rust, AF_NETLINK | none (verdict channel, no egress) |
| open | calls xdg-open for “Open release notes” | the process itself does not communicate |
| GTK3 / ksni (app only) | GUI and tray | none |
| ureq + rustls (roamswitch-updater only) | the daily updater’s one HTTPS GET | the only HTTP/TLS stack in the tree; confined to the updater binary |
No HTTP client, TLS stack, or telemetry SDK appears in roamswitch-core, roamswitch-daemon, roamswitch-app, roamswitch-mcp, or roamswitchkit. ureq + rustls are present only in roamswitch-updater (scripts/audit_no_network.sh enforces this in CI). There is no telemetry SDK anywhere.
Appendix B
table inet roamswitch { # Profile firewall (Air-Gap / balanced / lockdown)
chain input {
type filter hook input priority -100; policy <accept|drop>;
iif "lo" accept
ct state established,related accept
# balanced/lockdown: drop everything else
}
chain output {
type filter hook output priority -100; policy <accept|drop>;
# policy drop only during Air-Gap; only lo and ct established are allowed
}
}
table inet roamswitch_linkguard { # §4.5 — independent of the profile table
chain output {
type filter hook output priority 0; policy accept;
udp dport 53 queue num 92 bypass
tcp dport 53 queue num 92 bypass
tcp dport { 80, 443 } ct state new,established queue num 92 bypass
}
}
table inet roamswitch_dev_guard { # §4 Port anomaly guard / manual port isolation, separate table
chain input {
type filter hook input priority -10; policy accept;
iif != "lo" tcp dport { … } drop
}
}
table inet roamswitch_vpn { # §4 WireGuard backend kill-switch
chain output {
type filter hook output priority -150; policy drop;
oifname "lo" accept
oifname "roamswitch" accept
meta l4proto { icmp, ipv6-icmp } accept
udp sport 68 udp dport 67 accept # DHCP
ip daddr { <endpoint v4> } udp dport <port> accept
ip6 daddr { <endpoint v6> } udp dport <port> accept
ct state established,related accept
}
chain input { type filter hook input priority -150; policy drop; … } # symmetric
}
table inet roamswitch_ts { # §4 Tailscale backend kill-switch (looser)
chain output {
type filter hook output priority -150; policy drop;
oifname "lo" accept
oifname "tailscale0" accept
meta l4proto { icmp, ipv6-icmp } accept
udp sport 68 udp dport 67 accept # DHCP (v6 side is symmetric too)
ip daddr 100.64.0.0/10 accept # tailnet CGNAT
ip6 daddr fd7a:115c:a1e0::/48 accept
ip daddr 100.100.100.100 udp dport 53 accept # MagicDNS
ip daddr 100.100.100.100 tcp dport 53 accept
udp dport 3478 accept # STUN
udp dport 41641 accept # Direct WireGuard connection
tcp dport 443 accept # DERP fallback
ct state established,related accept
}
chain input { type filter hook input priority -150; policy drop; … } # symmetric
}
The ARP/NDP pinning of the gateway / IPv6 router / DNS resolver (§4) is done in the kernel neighbour table (ip neigh replace … nud permanent), not in nftables. Pinned entries are recorded in /var/lib/roamswitch/arp_lock.json.
Appendix C
Every claim in this document can be verified on your own machine. The commands below run on a host with the roamswitch package installed.
C.1 Outbound Traffic Verification
The daemon / app / MCP binaries link no HTTP / TLS library:
for b in roamswitch-daemon roamswitch-app roamswitch-mcp; do
ldd "$(command -v $b)" | grep -iE 'ssl|crypto|nghttp|curl' && echo " ^ in $b"
done
# → No output. The HTTP/TLS stack is statically linked only into roamswitch-updater.
The running daemon holds no internet socket:
sudo lsof -p "$(pgrep -x roamswitch-daemon)" -a -i
# → No output = zero TCP/UDP internet connections
sudo lsof -p "$(pgrep -x roamswitch-daemon)" -a -U | grep roamswitch
# → Only /run/roamswitch/roamswitch.sock (the link guard adds an AF_NETLINK socket,
# but this is a kernel channel, not an internet socket)
The only RoamSwitch-attributable egress is the daily updater:
watch -n 5 'sudo ss -tupn | grep -E "roamswitch-(daemon|app|mcp)" || echo "(none)"'
# → Always "(none)". None of these three ever opens an internet socket.
systemctl list-timers roamswitch-update.timer
sudo systemctl start roamswitch-update.service
journalctl -u roamswitch-update.service -n 20 --no-pager
# → A single HTTPS GET to lafine.net/updates/v1, then it exits.
Disable it and confirm total silence:
sudo systemctl disable --now roamswitch-update.timer
# or edit /var/lib/roamswitch/updates.json to set "enabled": false
sudo strace -f -e trace=network -p "$(pgrep -x roamswitch-daemon)" 2>&1 | grep -i 'connect('
# → Only connects to AF_UNIX / AF_NETLINK. AF_INET / AF_INET6 never appear.
C.2 Reproducible Defense Verification (Docker Test Suite)
All detection, control, and defense mechanisms of RoamSwitch (Air-Gap enforcement, self-healing after firewall clobbering, ransomware canary tampering, /tmp noexec, Yama LSM, homograph phishing interception, and credential leak detection — 17 items total) can be safely reproduced and verified 100% in an isolated container without affecting host networks or files, using the official Docker test suite provided in the public support repository (roamswitch-support
# Fetch and reproduce the verification suite (automated verification inside an isolated container, using the official distribution package)
git clone https://github.com/lafine1211/roamswitch-support.git
cd roamswitch-support/test/docker
docker build -t roamswitch-test .
docker run --rm --privileged roamswitch-test
The test container automatically fetches the official distribution package from the APT repository (lafine.net/apt) and evaluates each of the following systematic test items (SP-1–11, PENT-1–7) within an isolated network and mount namespace.
(1) Autonomous Network Defense & Air-Gap Self-Healing
| ID | Scenario | Verification Method | Pass Criterion |
|---|---|---|---|
| SP-3 | Air-Gap enable → egress drop | Send enable_air_gap IPC | policy drop applied to output hook; all outbound traffic blocked |
| SP-3b | Firewall forced purge & self-healing | Purge rules via nft delete table inet roamswitch | Anti-Clobber Self-Healing restores drop rules within 5 seconds |
| SP-3c | Fail-closed across daemon restart | Hard-kill daemon (SIGKILL) mid-Air-Gap and restart | Egress drop policy persists seamlessly across restart |
| SP-3d | Authorized Air-Gap recovery | Send authorized disable_air_gap IPC | Drop policy lifted, all marker files removed, egress restored |
| SP-8 | Preventive ARP/NDP pinning | Enable in isolated netns gateway environment → send spoofed gratuitous ARP | Gateway neigh entry remains PERMANENT; spoofed ARP ignored |
| SP-9 | VPN kill-switch (WireGuard) | Live WireGuard server in separate netns, import .conf → vpn_up | Handshake completes through inet roamswitch_vpn; zero leaks on tunnel death |
| SP-10 | VPN kill-switch (Tailscale) | Stub tailscale CLI + separate netns, tailscale0 IF | Off-tunnel plaintext TCP/80 and local DNS blocked; DERP and MagicDNS allowed |
(2) Kernel & Mount Hardening
| ID | Scenario | Verification Method | Pass Criterion |
|---|---|---|---|
| SP-11a | Network sysctl hardening | Run apply_kernel_sysctl_hardening IPC | rp_filter=1, tcp_syncookies=1, accept_redirects=0 reach target values |
| SP-11b | Memory protection & Yama LSM | Verify ptrace_scope and suid_dumpable | kernel.yama.ptrace_scope=1 (blocks snooping), fs.suid_dumpable=0 (no core dump) |
| SP-11c | /tmp & /dev/shm noexec defense | Run apply_mount_hardening IPC | /tmp and /dev/shm remounted noexec,nosuid,nodev; execution denied |
| SP-11d | Legitimate user execution preserved | Execute scripts in standard home directory (~/) | Runs unimpeded without breaking legitimate developer/admin workflows |
(3) Multi-Layer Malware & Ransomware Defense & Canaries
| ID | Scenario | Verification Method | Pass Criterion |
|---|---|---|---|
| SP-1 | Canary tamper detection | Truncate or rename decoy canary file (.xlsx) in ~/Documents/ | Tamper detected within seconds, triggering immediate Air-Gap |
| SP-2 | Ransomware bulk-encryption burst detection | Single process rapidly generates multiple high-entropy files | Offending worker process frozen with SIGSTOP; Air-Gap triggered |
| SP-2b | No false positive on shred | Run shred -n 3 -u across test files | Allow-list match prevents false-positive detection or freeze |
| SP-2c | Critical processes never frozen | Validate freeze target logic during burst detection | Critical daemons (dockerd, systemd, containerd) are never frozen |
| SP-4 | EICAR test string is notify-only | Write EICAR test string to ~/Downloads/ | No notification, notification-history entry only; file is never auto-quarantined |
| SP-4b | Real malware signature confirm-first | Write real signature outside watched folders | Raises modal approval dialog; quarantined only upon user confirmation |
| SP-4c | Quarantine Vault permission hardening | Inspect permissions of vault directory and sample files | Vault directory set to 0700, quarantined samples set to 0400 |
| SP-4d | Privileged IPC safe original-file restoration | Send restore_quarantine_file IPC | File restored to original path with user permissions (0644) and added to exclusions |
(4) Passive Link Guard & Phishing Defense
| ID | Scenario | Verification Method | Pass Criterion |
|---|---|---|---|
| SP-7 | mode = block hard enforcement | Connect to feed-listed or blocklistExtra hosts | Dropped at TLS layer (SNI parsed); unlisted safe hosts reach HTTP 200 |
| SP-7b | mode = warn fail-closed hold | Connect to warning host with no user response (timeout 8s) | Drops packets upon timeout (fail-closed); allowed only on explicit approval |
| SP-7c | mode = off complete teardown | Disable link guard | inet roamswitch_linkguard table is completely uninstalled |
(5) BadUSB Mitigation & Device Control
| ID | Scenario | Verification Method | Pass Criterion |
|---|---|---|---|
| SP-6 | Connected keyboard grandfathering | Check keyboards attached at daemon startup | Existing devices grandfathered; only subsequent hot-plugs grabbed |
(6) Penetration Testing Suite in Docker Environment (17 Checks)
| ID | Attack Vector | Simulation Detail | Defense / Detection Criteria |
|---|---|---|---|
| PENT-1 | C2 Exfiltration | Egress HTTP/TCP to C2 attempted under Air-Gap | All packets dropped at netfilter; zero leakage |
| PENT-1b | FW Force Deletion | Purge rules with nft delete table inet roamswitch | Anti-Clobber Self-Healing restores rules within seconds |
| PENT-1c | Air-Gap Safe Restore | Issue authenticated disable_air_gap IPC | Drop rules removed; markers deleted; connectivity restored |
| PENT-2 | /tmp Backdoor | Execute dropped backdoor script in /tmp | Denied by noexec mount protection (Permission denied) |
| PENT-2b | /dev/shm Shared Memory | Execute binary payload placed in /dev/shm | Blocked by noexec mount defense |
| PENT-2c | User Space Preservation | Run script in legitimate user directory (/home/tester/) | Unimpeded execution; zero workflow interruption |
| PENT-3 | ptrace Memory Snooping | PTRACE_ATTACH to target to extract secrets/tokens | Denied by Yama LSM kernel.yama.ptrace_scope=1 |
| PENT-3b | Core Dump Secret Scraping | Trigger process crash to obtain memory core dump | Prevented by fs.suid_dumpable=0 |
| PENT-4 | Canary Tampering | Zero-out canary decoy file in ~/Documents/ | Real-time detection of size anomaly & hash change |
| PENT-4b | Canary Bulk Wiping | Mass deletion of canary decoy files | File disappearance detected immediately, raising incident |
| PENT-5 | IP Address Spoofing | Transmit packets with spoofed source IP | Dropped by rp_filter=1 (reverse path filter) |
| PENT-5b | SYN Flood DoS | Flood half-open TCP connections | State table protected by tcp_syncookies=1 |
| PENT-5c | ICMP Route Hijack | Inject fake ICMP Redirect packets | Ignored due to accept_redirects=0 |
| PENT-6 | Cyrillic Homograph Phishing | Navigate to gооgle.com (Cyrillic homograph) | Identified by LinkGuard, scored 40 (DANGEROUS / block) |
| PENT-6b | Raw IP & Plaintext Traffic | Direct outbound connection to raw IP | Accurately flagged as safety risk factor |
| PENT-7 | OpenAI API Key Leak | Expose sk-proj-... token in code or logs | Detected via Shannon entropy analysis & masked |
| PENT-7b | GitHub Token Leak | Expose ghp_... personal token in code | Matched by token pattern and entropy verification |
| PENT-8 | Kernel LPE (Frag Gap) Preemption | Unprivileged unshare -U -r (CVE-2026-53362 attempt) | Rejected immediately by user.max_user_namespaces = 0 (Operation not permitted) |
| PENT-9 | eBPF Threat Sensor & Process Freeze | Critical/Emergency alert injected via Falco eBPF socket | Process frozen via SIGSTOP within milliseconds (state: Ts); Air-Gap deployed |
(7) Penetration Testing Summary (Standalone Hardened vs. eBPF Enhanced)
Summary of results from the full penetration test executed in an isolated Docker environment (Target: Ubuntu 24.04 with RoamSwitch Client Edition; Attacker: Nmap / curl / hping3; see official report docs/CLIENT_PENTEST_REPORT.ja.md):
| Verification Category | Without Falco (Standalone Hardened) | With Falco (eBPF Enhanced) | Defense Verdict |
|---|---|---|---|
| External Port Scan Resistance (Nmap SYN) | PASS (100% Filtered / DROP in Lockdown) | PASS (100% Filtered / DROP in Lockdown) | Total External Stealth |
| External Direct Access Cutoff (TCP/80, 3306) | PASS (Timeout drop / zero response) | PASS (Timeout drop / zero response) | Zero Inbound Exposure |
| Kernel LPE (Frag Gap) Mitigation | PASS (unshare blocked via sysctl) | PASS (eBPF alert + SIGSTOP freeze + Air-Gap) | Defense-in-Depth Verified |
| Unauthorized Port Detection (Anomaly) | PASS (0.0.0.0 bind flagged HIGH risk) | PASS (0.0.0.0 bind flagged HIGH risk) | 100% Detected & Guided |
| Log Bloat & CPU Overhead Prevention | Zero log footprint (uninstalled) | PASS (95% noise cut / 2MB buffer / 50MB cap) | Desktop Optimized |
C.3 Known limitations
| Limitation | Impact | Mitigation |
|---|---|---|
On some kernels, FAN_MARK_MOUNT on tmpfs (/tmp) delivers no events | file writes under /tmp are not monitored by fanotify | /tmp is ephemeral and not a monitored user folder; ransomware targets ~/. Mark failures are logged explicitly |
| No fanotify delivery on container overlayfs / tmpfs | in-container entropy-burst detection cannot be validated | validated on real hardware (ext4); the harness auto-SKIPs via a probe |
RoamSwitch’s nftables operations share the netfilter space with Docker’s iptables-nft | RoamSwitch rule operations can interfere with Docker’s NAT rules | documented as an operational caveat; isolated in the dedicated inet roamswitch table, but whole-ruleset operations such as nft flush ruleset should be avoided |
| The link guard sees a browser’s DoH only via TLS SNI; Encrypted Client Hello (ECH) hides that too | once ECH is widely deployed, an ECH-using connection to an ECH-supporting site is not identifiable by hostname | the local blocklist and heuristics still act on DNS/HTTP and non-ECH TLS; DNS threat protection (§4.5, opt-in) covers the resolver path |
The daily updater’s freshness depends on the release cadence when updates.enabled = false | with updates off, the feed is only as current as the last apt upgrade | the highest-severity detections (brand homograph) need no feed; opt back in, or enable threat-protection DNS, for always-current coverage |