§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) countermeasures (denying unprivileged user namespaces on untrusted networks as a Frag Gap CVE-2026-53362 mitigation is optional and off by default), eBPF runtime threat sensor (Falco / Tetragon) integration with sub-second process freezing (SIGSTOP) and Air-Gap network isolation, a 27-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; by default the only thing sent off the machine is a request for signed detection data, carrying nothing about you” is guaranteed at the code level. Opt-in features that an operator must explicitly enable (such as event forwarding) are exceptions to that default; they are all listed in the table below and in §9.4. 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 | By default, 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. The only things that can communicate are the opt-in features an operator explicitly enables, listed in the next row (“Exceptions to the default”) and in 9.4. |
| Exceptions to the default (all opt-in) | The following features communicate externally only if an operator explicitly enables them. Their destinations are only those the operator configures; there are no built-in destinations. (1) Event forwarding (forward_enabled, default OFF) — sends detection events to a syslog / JSON Lines file / signed webhook that the operator specifies. (2) Fleet (Business tier, device-side code only) — a device that is not enrolled sends nothing and does not even resolve the Fleet host name; the management service is not deployed or released. Everything else, including user-triggered update checks, is listed in 9.4. |
| Receive-only updates | The one outbound request the product makes by default is a once-a-day GET of a fixed, signed manifest from lafine.net/updates/v1. When ClamAV is installed, each run also starts ClamAV’s own updater, freshclam; that traffic belongs to ClamAV and is listed separately in §9.4. From the manifest it fetches the scam-site list for the link guard, the latest app version, the kernel, OS-package (Debian / Ubuntu / Arch / RHEL family / SUSE) and language-package (npm / PyPI / crates.io / RubyGems / Packagist / Go / Maven) CVE maps, the active-vulnerability-scan CVE map, and the popular-npm-package-name list used for typosquat detection. Every day it fetches only the manifest and its signature (two GETs); the link-guard list, the active-scan CVE map and the npm list are fetched only when the manifest shows a newer version, and the kernel and package CVE maps only when about 30 days have passed since the last refresh. The Ubuntu CVE map is fetched only by devices running a supported Ubuntu release, and a device that does not have it yet fetches it on its next daily run instead of waiting for the 30 days. It carries no query string and no cookies, the only header that names the software is a fixed User-Agent: RoamSwitch-Updater, and no information about you is included. updates.enabled = false turns it off entirely; the product then runs on bundled data and, unless you enable an opt-in feature, has 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. By default the daemon opens no TCP/UDP socket (the opt-in features an operator enables are the exception, §9.4); the link guard uses only the AF_NETLINK NFQUEUE verdict channel (kernel ↔ userspace, no packet egress). IPC calls that change state are accepted only from root or from a UID with a local login session (from 1.10.4; read-only calls are unchanged). |
| 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, and the package ships eight executables (roamswitch-daemon, roamswitch-app, roamswitch (CLI), roamswitch-mcp, roamswitch-updater, roamswitch-honeytokens, roamswitch-incident-capture, and the experimental, disabled-by-default roamswitch-xdp-gate). Shared logic lives in the roamswitch-core library, and roamswitchkit is the client SDK. 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 countermeasures (denying unprivileged userns is optional, off by default) · 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-honeytokens Helper started by the daemon (plants and watches decoy credentials) roamswitch-incident-capture Helper started by the daemon (collects an evidence bundle on containment) 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 (the directory is 0755 and the socket is 0666). Access control is done by checking the caller, not by file permissions: the daemon reads the peer UID with SO_PEERCRED, refuses system accounts (UIDs 1 to 999 and nobody), and accepts state-changing calls only from root or a UID that owns a local login session (§1). 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), accepting only a file that the account itself owns, that is not a symbolic link, and that other users cannot write. Configuration never leaves the machine; there is no account registration and no cloud sync.
§4
The sentinel loop (3-second cycle) 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, lo and ICMP / ICMPv6 allowed; exposed-port exposure monitored. |
| lockdown (away protection) | Public Wi-Fi / unregistered network | All inbound packets stealth-dropped. Sharing services are stopped automatically and restored on return to a trusted network (on by default; can be turned off any time). |
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. - Low-confidence triggers lift on a short timer — a plain manual Air-Gap with no warning context (a GUI button or
roamswitch airgap) is a low-confidence trigger and is fully restored on a short timer (airgap_low_confidence_secs, default 600 seconds), so a mistaken press or a false positive cannot cut the machine off for long. An ARP anomaly outside lockdown, a port anomaly and an eBPF Error-tier event are also classed as low-confidence in the shared state machine, but the Client never engages the Air-Gap automatically for them; they only produce a notification or a log entry, or block the single port. - High-confidence triggers never restore silently — canary tampering, an eBPF Emergency/Alert event (a Critical event only when the single process could not be contained), ARP spoofing involving the gateway during lockdown, and an Air-Gap pressed in response to an ARP warning (plus, only when opted in, a high-severity execution-recorder rule; §5) are not restored automatically, however much time passes. From the short timer onward, each cycle re-checks whether the cause persists (the frozen PID still alive, the canary rewritten again, the ARP anomaly continuing, and so on) and releases, with a notification, only when the cause is verified gone. An inconclusive check counts as “not cleared”. A ransomware entropy burst also engages the Air-Gap, in addition to freezing the process with
SIGSTOPand a notification, and is treated as a high-confidence trigger in the same way. - DEGRADED mode — if a high-confidence isolation reaches the hard cap (
airgap_hard_cap_secs, default 3600 seconds;0= no cap) with no release by the user and no verified-gone cause, it moves to DEGRADED. On the Client, DEGRADED is as tight as FULL — everything except loopback is dropped, established connections included; the difference is that it never re-opens on a timer. Release is by the local user (the GUI release button orroamswitch emergency-restore) or by a re-check that verifies the cause is gone. Air-Gap also blocks the Wi-Fi radio by rfkill by default (airgapAutoRfkill), and DEGRADED keeps it blocked unlessairgap_degraded_lift_rfkillis set to true. - Transitions are recorded and notified — the move from FULL to DEGRADED, each kind of automatic release, and a release by the user are all written to the incident timeline and raise a desktop notification. The isolation state is stored in
/run/roamswitch/airgap_isolation.jsonand survives a daemon restart (a restart resets the ARP re-check’s consecutive count, which errs toward staying isolated — fail-closed).
table inet roamswitch {
chain input {
type filter hook input priority -100; policy <accept|drop>;
iif "lo" accept
ct state established,related accept
# open is policy accept with no rules; balanced / lockdown are policy drop (ICMP / ICMPv6 are allowed in balanced only)
}
chain output {
type filter hook output priority -100; policy <accept|drop>;
# During Air-Gap the same table is rebuilt: policy drop on input and output, only lo is allowed (established connections are not let through either)
}
}
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 | no notification, no block |
block (default) | connection dropped + an allow dialog (once per host) | no notification, no block |
Only a feed hit or a brand-homograph host is ever hard-blocked; everything the heuristics alone flag stays a warning-level verdict, with no block and no notification. 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. Choosing “Allow” in the approval dialog adds the blocked host to linkGuard.allowlist permanently, until it is removed in the settings.
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’s Ed25519 signature and SHA-256 are checked by the daily updater before it installs anything, and data that does not match is not adopted. The link guard loads the installed feed (or the one shipped in the package); with neither present it runs on “heuristics only”, never “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 is set and the profile is lockdown. On open and balanced the tunnel is not brought up, and with Tailscale 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 MAC answering for multiple IPs (a duplicate MAC) and for a suspicious sudden change of the gateway MAC. In lockdown: when the gateway is involved, 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.
Verifying the cause before releasing an Air-Gap (ARP re-check)
An Air-Gap caused by ARP spoofing (detected during lockdown, or pressed in response to an ARP warning) is released as “cause gone” only when all of the following hold. The daemon reads the kernel neighbour table (ip -j neigh show, falling back to /proc/net/arp) and the default route, and the default gateway’s MAC must equal the trusted MAC recorded at activation (in state REACHABLE, STALE, DELAY, PROBE or PERMANENT, with no other IPv4 host on the same device claiming that MAC), for arp_recheck_consecutive consecutive checks (default 3) spanning at least arp_recheck_min_secs seconds (default 60). Only when the neighbour entry is missing or in a failed state and the link is up (carrier present, operstate up, Wi-Fi not blocked by rfkill), it sends a single ARP who-has to the gateway (at least 20 seconds apart, over AF_PACKET) to check (arp_recheck_active_probe, default true). If any reply carries a MAC other than the trusted one, that is a mismatch. A mismatch, a missing entry, a duplicate MAC, a read failure, or no recorded trusted MAC all count as “not cleared” (fail-closed) and reset the count. Known limitation: an attacker who clones the real gateway’s MAC cannot be detected by any MAC comparison, cached or active.
A detected ARP spoof triggers the automatic Air-Gap only when the gateway is involved: the gateway's MAC changed, or the gateway's IP shares one MAC with another IP. Another host's MAC changing, or one machine answering for several IPs with one MAC (a PC running virtual machines, a NAS with an alias address), is only reported and does not cut the network. The same rule applies to the release check: only a duplicate that involves the gateway counts as “the cause persists”. When this rule was introduced in 1.10.7 (2026-09-26), on a real LAN, a forged gateway ARP sent from another machine triggered the Air-Gap in about 1 second, and it was released automatically about 11 minutes after the attack stopped (the default 10 minutes plus the 60-second confirmation), even while two unrelated IPs kept answering with one MAC. Before this rule, that harmless duplicate alone triggered a full cut-off and was never released.
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.
DNS Tunneling / Exfiltration Detection
Runs at all times as part of the existing NFQUEUE DNS path used by the passive Link Guard (there is no separate toggle for it). Counts a DNS query as “suspicious-shaped” when it matches any of the patterns characteristic of DNS-tunneling tools such as iodine or dnscat2 — a query label over 40 bytes AND Shannon entropy over 3.8, or a TXT/NULL record qtype — and only notifies once 10 or more such queries occur within a 60-second sliding window (with a 300-second cooldown between repeat notifications for the same apex domain, which grows to 600, 1200 and at most 1800 seconds while notifications continue and resets after a quiet period of twice the cooldown). Only DNS (UDP/TCP port 53) is inspected; no other traffic is examined. It is notify-only and never blocks automatically. A TXT query under clamav.net alone is not counted, because freshclam uses it for freshness checks. The underlying NFQUEUE rule carries the bypass flag, so if the detection process itself is down or unresponsive, DNS traffic passes through unaffected — name resolution is never blocked by this feature failing (the same fail-open design as the passive Link Guard itself).
Known limitation — long, hyphen-separated hostnames (for example, prod-us-east-1-web-server-node-0042) and a single global counter that did not distinguish destination domains for TXT/NULL bursts were previously measured to trigger false positives here. Both were fixed on 2026-09-23. The fix is a configurable entropy allowlist plus keying the burst counter per apex domain (a simple approximation using the last two labels of the QNAME, not a strict Public Suffix List implementation). The allowlist ships with 16 major CDN/cloud/PaaS suffixes by default (cloudfront.net, akamaiedge.net, akamaized.net, edgekey.net, edgesuite.net, fastly.net, azureedge.net, googleusercontent.com, amazonaws.com, r2.dev, workers.dev, vercel.app, netlify.app, supabase.co, firebaseapp.com, web.app), and operators can add their own internal domains via LinkGuardCfg.dns_tunnel_entropy_allowlist (the link guard settings of roamswitch-app have a dedicated comma-separated field for it). The allowlist only suppresses the label-length-plus-entropy check; TXT/NULL qtype detection applies regardless of the allowlist, which prevents an attacker from disguising a tunnel under an allowlisted domain (the only exception is TXT under clamav.net, which freshclam uses for freshness checks, through a separate tiny exemption list). The burst counter is now keyed per apex domain, which eliminates the false positives caused by a single global counter that ignored the destination domain (the underlying 60-second sliding window is unchanged; the repeat-notification cooldown starts at 300 seconds and grows to at most 1800 seconds while notifications continue). Real malicious traffic from tools such as iodine or dnscat2 always rotates labels and qtypes under a single attacker-controlled domain, so narrowing to per-apex-domain counting does not reduce detection effectiveness.
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.
Coverage — this DOCKER-USER chain inspection covers the iptables (IPv4) backend, IPv6 and Docker’s native nftables backend, and re-checks and repairs itself every 60 seconds (on the desktop edition too, from 1.10.4; earlier versions covered only the iptables (IPv4) backend).
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 detection is also available through the audit_secrets MCP tool (pass text or path) and through the public Rust SDK roamswitch-linux-kit (audit_secrets() for text, audit_secrets_path() for a file or directory). Content is never transmitted anywhere.
SSH brute-force detection and blocking (only while sshd is running)
The client daemon runs the same engine as the Server Edition (ssh_guard_enabled, true by default). There are three differences. First, it works only while sshd is running: every minute it checks whether anything listens on the SSH ports and stops after three misses in a row. On a machine without sshd it does not follow the log and does not touch an nftables table. Second, the settings are the same 10 keys (ssh_guard_*) with the same defaults, kept in config.json and picked up within about a minute. Third, notifications are desktop notifications and an entry in the client's incident record, and it never starts the Air-Gap. A source whose failures reach the threshold (by default 6 within 600 seconds) loses its new connections, and the SSH connections it already had open are cut at the same time (an authenticated session and an exempt source are never cut). When fail2ban (with an enabled sshd jail) or CrowdSec already guards sshd, it only detects and notifies. Bans are listed and lifted with sudo roamswitch bans and sudo roamswitch unban <IP>; there is no GTK screen for it, only config.json and the commands. At the client's default protection levels inbound traffic is closed, so connections may not reach SSH at all.
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. A desktop notification, an incident-timeline record and an evidence bundle follow. This detection also triggers Air-Gap (the network-wide cutoff), as a high-confidence trigger like canary tampering and high-severity eBPF events.
Measured on a real machine (Ubuntu 24.04, kernel 7.0) with a reproduction program that rewrites 40 files (measured on 2026-09-26 against the daemon of the release that carries the partial-encryption detection, 1.10.7). A full encryption was detected after the 20th file and the process was frozen at the 31st. It is still detected when the process renames itself to python3 or git: the allowlist now checks that the executable and the script name agree with the name, not the name alone (before, all 40 files went unnoticed). cp -r, rsync -a, tar -x and Python scripts are identified when the write is seen, so they no longer raise a false detection even when they finish quickly (before, they had already exited when the burst was evaluated and were reported as an “unknown process”). Partial encryption (16 bytes out of every 32, the way LockBit-type ransomware writes) stays below the 7.92 whole-file average, so it used to go undetected; since 1.10.7 it is detected. The rule looks for 16-byte blocks that alternate between random-looking and text-looking; it caught 94% of the simulated files and raised 2 false positives (0.01%) among 19,997 ordinary files. A reproduction program that writes at full speed, with no pause per file, finishes all 40 files before the 20-file threshold is evaluated, so the process had already exited when it was stopped; with 0.2 seconds per file it stopped at the 20th. Head-only encryption, skipped-region encryption and encrypted data written as Base64 are not counted as detections: they are only logged and nothing is frozen, because they would misdetect ordinary files. Ransomware written in a genuine allowlisted program (such as python3) also cannot be told apart by name.
- Separation of YARA scanning and entropy tracking — YARA malware inspection is applied to written files with no exclusion by extension, including
~/.cache/,node_modules/,/tmp/and archives (except the user-configuredscan_exclusions, the quarantine vault,/dev/shmand in-memory or deleted files, places such as/proc,/sysand/dev, and files written by system processes such as the daemon itself, ClamAV and the desktop base; only the first 256 KB of a file is read). Meanwhile, ransomware entropy burst analysis excludes naturally compressed formats (.zip,.tgz,.tar.gz,.gz,.png,.jpg,.mp4,.wasm), files that still begin with a genuine compressed or media header (since 1.10.25) and verified package managers (npm,npx,cargo,rustc,dpkg,apt,tar) operating in build/cache trees to reduce 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 protected-process list (
PROTECTED_PROCESS_NAMES:systemd,dockerd,NetworkManager,sshd,gnome-shell, …); a process whose name matches and whose executable lives in a system directory (such as/usror/opt) is never frozen, preventing a machine-wide hang. A look-alike program in/tmpor/homeis not protected.
Web application rules (web shells and injected code)
Dedicated rules detect PHP, JavaScript, JSP and ASP web shells, injected obfuscated code, PHP hidden in images and .htaccess hijacking of execution. They are separate from the built-in ELF and shell detection and are written in a small language of literals, bounded-gap sequences of literals and two statistics (an extremely long line, a huge Base64 blob). With no regular expressions, evaluation cannot blow up or hang, and even a hostile rule file can only cause false positives. No signature is extracted from a real sample or tied to a named malware family: the 18 rules describe publicly documented techniques only (executing the output of a decoder, passing request values straight to execution, execution-capable PHP in upload directories, PHP inside an image, .htaccess handler mappings, injected scripts built with unescape, and in-browser miner libraries).
The rules come in three layers: built in, installed by the daily updater (11.3) after signature verification, and your own files in /etc/roamswitch/rules.d/*.json (only files owned by root that group and others cannot write are read). Severities are critical, high, medium and low; critical and high go through approval or quarantine, medium and below are report-only. Automatic quarantine is critical only by default (quarantine in /etc/roamswitch/web_rules.conf), and on the Client Edition a detection at write or execute time is still decided by the user in the approval dialog. Anything under tests/, fixtures/ and .git/ is skipped by default because test code quotes the same constructs.
After a detection, an executable placed at /etc/roamswitch/hooks/post-detection receives the hits as JSON on standard input (only a root-owned, non-writable file in a safe directory runs; there is no shell, the environment is minimal and it is stopped after 30 seconds). The optional account response (off by default) revokes the login shell of the owner of a quarantined file. It does nothing for root, accounts below account_min_uid (default 1000), anyone who is logged in, or the daemon's own user; the previous shell is recorded and roamswitch web-rules unlock-account restores it. Measured false positives: 0 in WordPress core (2,409 files), phpMyAdmin (3,356 files) and npm dependencies (14,743 files). Limits: only the first 256 KB (on write/execute) and 4 MB (scan) are read, split-up names such as 'ev'.'al' are not followed, and no per-malware signatures are shipped because there are no real samples.
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.
Forensic Evidence Capture Bundles
When the ransomware entropy-burst guard fires, or a Critical-severity event from the eBPF runtime threat sensor results in successful per-process pinpoint containment (SIGSTOP plus network isolation), roamswitch-incident-capture is launched and saves an evidence bundle to <state directory>/incident-evidence/<timestamp>-<short id>/. What is captured: a bounded 30-second packet capture via tshark on the default-route interface (recorded as skipped if tshark is not installed), and when a PID is passed, a /proc/<pid> snapshot of the target process (status, cmdline, maps, an environ with values that look like secrets replaced by [REDACTED], a list of open file descriptor link targets — contents are not captured — and the process's ancestor chain). A single artifact's capture failing is not treated as fatal; it is recorded and the rest of the capture continues. For eBPF events the target PID is passed. For the entropy-burst guard it is passed when the writing process could be identified; if that process had already exited by the time the burst was evaluated, the tool starts without a PID and captures only the packets. Evidence capture does not run when pinpoint containment is unavailable or fails and falls back to host-wide Air-Gap isolation, nor for Emergency/Alert-level events that trigger host-wide isolation directly. The packages up to 1.10.4 did not include roamswitch-incident-capture, which this feature needs, so it did not run on installs from the packages. It has been bundled since 1.10.5.
The results are recorded in manifest.json, which also embeds the SHA-256 hash of every other file in the bundle (so later tampering can be detected). Bundles are never deleted automatically — old bundles are removed only when roamswitch-incident-capture prune (default retention: 30 days) is run explicitly.
Captured evidence is not verified at capture time. To check for tampering afterward, run roamswitch-incident-capture verify <bundle directory> [--json]. It re-hashes every file currently in the bundle and compares the result against the values recorded in manifest.json, reporting each discrepancy individually as modified, missing, or untracked (a file present that the manifest never recorded).
Known limitation — this verification compares the SHA-256 of manifest.json with records kept outside the bundle, in two places: a hash chain outside the bundle (evidence_anchor.log, each line committing to the previous one, set append-only where the filesystem allows) and the system log (tag roamswitch-evidence). Rewriting manifest.json after capture makes verify fail, and so does editing or removing a line of the chain, because the chain then breaks. It does not stop an attacker with root who can rewrite both the chain and wherever the system log is forwarded; forwarding the system log to another machine is recommended. Signing is not implemented. A bundle without a record (captured by an older version) is reported as “no record”, meaning there is no outside confirmation for it.
Honeytokens (Credential Decoys)
On daemon startup, RoamSwitch idempotently deploys decoy credential files at known real paths that attackers and automated credential-stealing tools actually scan — ~/.aws/credentials, ~/.ssh/id_rsa, and ~/.docker/config.json, ~/.pypirc, ~/.env.backup (under the invoking user's home directory) — each containing a ROAMSWITCH_HONEYTOKEN marker. A real file that already exists at any of these paths is never overwritten (deployment always checks for existence first). Real access to a honeytoken can also trigger a cascading deployment of related additional decoys. The packages up to 1.10.4 did not include roamswitch-honeytokens, which this feature needs, so it did not run on installs from the packages. It has been bundled since 1.10.5. Since 1.11.1, every docker command that RoamSwitch itself runs is started with an empty private directory as DOCKER_CONFIG, so it does not trigger the decoy ~/.docker/config.json. A read by any other program is still detected as before.
A background watcher uses notification-class fanotify (per-file FAN_OPEN/FAN_ACCESS marks on each decoy) to watch reads and opens of every decoy; where fanotify is unavailable (for example when not running as root) it falls back to inotify. Since no legitimate process has any reason to read these files, an access is treated as a strong signal of an intruder's post-compromise reconnaissance (MITRE ATT&CK T1552); RoamSwitch attempts to identify the accessing process and records the event in the incident timeline with severity Critical (no desktop pop-up is shown; it is forwarded if event forwarding is enabled). Accesses by RoamSwitch's own executables, and by the OpenSSH client family (ssh, scp, sftp, ssh-add, ssh-agent) on the SSH decoys, are not recorded, to avoid false alarms; an access whose executable cannot be identified is recorded. This is record-only — it does not itself freeze or block the accessing process.
Known limitation — the body of the ~/.ssh/id_rsa decoy contains readable text and remains an invalid base64 body for a real OpenSSH private key. However, most secret scanners flag files purely by matching the -----BEGIN ... PRIVATE KEY----- header, so we judge the real-world impact on detection rate to be limited. Separately, the accessing process is normally identified from the PID carried by the fanotify event; only when fanotify is unavailable and the watcher falls back to inotify is a fuser call used, which can fail to obtain a PID or process name for a very short-lived process that exits immediately after reading the file. In that case the process is simply recorded as unknown, which does not affect detection of the access itself (the event still fires and is still recorded).
fanotify monitoring · Quarantine Vault · ClamAV
- The daemon monitors
/home,/tmp,/var/tmpand/dev/shmwith filesystem-level marks (since 1.10.18; a mount-level mark is only the fallback when a filesystem mark is refused) and watches write completion on/as a whole at the filesystem level, viaFAN_CLASS_CONTENT(fallbackFAN_CLASS_NOTIF). From 1.10.4, permission events cover execution only (FAN_OPEN_EXEC_PERM) andopenis handled as a notification (fanotify_open_permrestores the old behavior). Executables already scanned clean are cached for 60 seconds, and the daemon’s own child processes are excluded from monitoring. - Notify-first, no silent quarantine (fanotify guard). In the fanotify guard, 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. The download guard (watching~/Downloadsand~/Desktopby default, pluswatched_folders) is a separate path: a file that YARA or ClamAV flags (EICAR excepted) is moved into the vault at once, without a prompt, and a notification follows. - 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 real malware (in the fanotify guard, only what the user confirmed) is relocated to the root-only
/var/lib/roamswitch/quarantine/(it used to be each user’s~/.local/share/roamswitch/quarantine/; the old location is migrated automatically at startup). The vault is root-owned0700; each sample is root-owned0400(read-only, non-executable). Users can act on the vault only through the daemon’s IPC, and see, restore and delete only the items they quarantined themselves. A restore is written by a child process that has dropped to the calling user’s privileges, as a0644file (an existing file is never overwritten). - The permission-event loop never reads the config file — config is read by a dedicated background thread into a
Mutex<GuardConfig>snapshot; hot-path config accessors just lock and copy (reading the head of the file about to be executed, to judge it, is separate). This is a design invariant: blocking onFAN_OPEN_EXEC_PERMwould freeze the machine. - When ClamAV is present (
clamav_enabled, on by default), new files that appear in the download guard’s watched folders (~/Downloadsand~/Desktopby default) are scanned against the local DB after the built-in YARA scan. Windows-style executables such as.exeraise a notification, Linux distribution formats (.sh,.deb,.elf, …) are only recorded in history, and dangerous AI models (.pkl,.pt) raise a notification with their download source. 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.
Process-execution recorder (local only)
A local-only recorder that logs exec events and uid changes through the netlink process connector (proc connector). It records the executable path, the command-line arguments (capped at 64 arguments, 512 characters each and 4096 in total, with control characters escaped), the parent-process lineage, the uid, the working directory, the container ID, the executable’s SHA-256, and similar fields. It is on by default, but defaults to off on machines with less than 1 GiB of RAM (an explicit exec_recorder_enabled setting wins). Its own CPU use is capped at 3% of one core (exec_recorder_cpu_budget_pct) and it runs at nice 10. If the process connector cannot be opened it degrades to a low-rate /proc scan (which misses short-lived processes).
- Storage — records are stored under
/var/lib/roamswitch/exec_logas JSON Lines split into 8 MiB segments (0600). The default retention is 200 MB / 14 days (exec_recorder_retention_mb/exec_recorder_retention_days). A hash chain makes tampering detectable but not preventable (an attacker who gains root can rewrite the whole chain). Search withroamswitch events search|tree|export|verify; the read-only MCP tools for AI clients aresearch_exec_eventsandget_process_tree. - Notify-only correlation rules (EXR-001 to EXR-010) — ten rules: a shell spawned by a web service, execution from
/tmp, an in-memory file (memfd) or a deleted file, execution right after a download, a base64/eval one-liner, gaining uid 0 without a setuid path, execution of a binary placed right after a cron/systemd change, execution after/etc/ld.so.preloadchanged, a local filesystem snapshot (btrfs, LVM) deleted by anything other than a known manager such assnapperortimeshift(EXR-008, 1.10.11), one actor probing for 8 or more attack tools withwhichand the like within 30 seconds (EXR-009, 1.11.0), and a scripting runtime such as python or node starting 3 or more attack tools within 2 minutes (EXR-010, 1.11.0; a person typing under sshd, login or a terminal is excluded). Each rule carries a MITRE ATT&CK technique ID. Rules only notify and never isolate on their own; only if an operator explicitly enablesexec_rules_isolation_optin(default false) is a high-severity rule treated as a high-confidence Air-Gap trigger (§4). - Nothing leaves the machine — the raw log is never forwarded. Only if event forwarding (§9.4) is enabled are the rule notifications (severity, rule ID, process name, summary) and the segment seal hashes sent, and only to the destination the operator configured.
Credential-file access watch (on by default, notify only)
On by default (credential_access_watch_enabled is true; set it to false to stop it, or use the switch on the malware-guard screen). It notifies when an unexpected program opens an SSH private key (~/.ssh/id_*), ~/.aws/credentials, the credential files under ~/.config/gcloud/ (four kinds, such as credentials.db) or the credential/cookie store of a major browser (notify only). It merely sets the notification-class fanotify event FAN_OPEN on a small number of individual files, never blocks, freezes or denies anything, and the kernel does not wait for a reply. The executable name of the program that opened the file is checked against per-kind allow lists (ssh / git / gpg-agent, browsers, aws / terraform / kubectl and so on), and repeat notifications for the same (program, file) pair are suppressed for 10 minutes. Backup tools (restic, borg and so on) and indexers (tracker, baloo and so on) are allowed from the start. Add any other program you do not want notifications for to credential_access_watch_allow in config.json. For AWS credentials, tools that run as python, python3 or ansible, such as boto3 scripts and Ansible playbooks, are allowed (for gcloud, python and python3); a python -c one-liner still notifies. The price of that allowance is that a credential stealer written as a python script file does not notify either. Other interpreters such as node or ruby, and general-purpose search and sync tools such as grep or rclone, are deliberately not allowed, because a program that steals credentials looks exactly the same, so they may produce notifications. A very short read such as cat or head, where the program exits before RoamSwitch can identify its executable, may not be reported (a read by a program that keeps running for a while is reported). RoamSwitch does not use the permission-class fanotify to avoid this limit, because it could stop the system. Changes take effect within about 30 seconds, with no restart.
The intrusion path in file-tampering notifications
The notifications of the client's critical-file integrity monitoring (FIM) and of the dependency-lockfile monitoring also name the process that made the change (account, executable, command) and the SSH login it belongs to (source, time, authentication method). This is fim_intrusion_context_enabled in config.json, true by default, and nothing is sent anywhere. A credential-file access notification gets one added line only when the process belongs to an SSH login. How it works, which values it trusts and the three answers it keeps apart (found / no SSH login was found / the login path could not be determined) are the same as on the Server Edition. Something that could not be checked is never reported as "not a login". The record is kept in /var/lib/roamswitch/fim_intrusions.json and in the recentIntrusionContext field of the MCP tool verify_fim.
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.
- Handling of 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. The diagnosis checks and shows the state ofuser.max_user_namespacesandkernel.unprivileged_userns_clone, but because disabling them can break browsers, Flatpak, Electron and rootless containers, the Client edition does not recommend it and leaves it out of the one-click hardening (ifuser.max_user_namespaces = 0is set, the report advises restoring the default). Disabling is a stopgap that only closes attack paths which need a user namespace; it does not stop other routes. Settingkernel.unprivileged_userns_clone = 0only while on an untrusted network is applied only when you enableadaptive_security.untrusted_network_actions.auto_enable_userns_restrictioninguard.yaml(off by default; it does nothing on kernels without that setting). The Server Edition applies it permanently by default (harden_userns_enabled). The real fix is updating to a patched kernel and rebooting. - 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] ├─► ② Network containment: Critical blocks only that process's user (roamswitch_isolate), Emergency/Alert use 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 & Network Containment) — When an event with priority
Critical(such as kernel LPE, namespace escape, or ransomware execution) is received, the daemon sendsSIGSTOPto the offending PID to pause it completely (state:Ts), blocks only the traffic of that process’s real user (or of its cgroup, for a container) in theroamswitch_isolatetable (pinpoint isolation), and raises a desktop notification. If no PID is identified, if the process is protected and the freeze is refused, if isolation fails (for example for a root process), or if the priority isEmergencyorAlert, the daemon deployspolicy dropacrossinet roamswitch(Air-Gap) and also sendsSIGSTOPwhen a PID is known. Events belowCriticaltrigger no containment and are only recorded in history. - Low-overhead desktop tuning & log-bloat prevention (RoamSwitch Optimized) To curb battery drain, CPU load, and disk exhaustion on laptops and workstations, an optimized Falco profile (
99-roamswitch-optimized.yaml) and a logrotate policy are provided for environments that run Falco. The Client packages and the desktop mode ofinstall.shdo not install Falco and do not place this profile (the Server Edition packages andinstall.sh --serverdo). If you use Falco with the Client, you install Falco yourself and, if you want it, place the profile yourself. The main settings are as follows.- 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 (Yama ptrace scope, core-dump suppression and network protections, applied at each profile switch), 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. If nobody approves within 3 minutes, the keyboard is no longer released by default (from 1.10.4; earlier versions failed open after 3 minutes, and enablingusb_keyboard_timeout_releaserestores that behavior).
Rogue USB mass storage
When an unregistered USB mass-storage device (no HID interface) is inserted, and the guard is enabled, it is held with authorized=0 (safe for a non-HID device) and an approval dialog is shown, whether or not the network is trusted. A drive that is mounted or used as swap (an external disk in use, a live USB stick) is not held, because that would be an abrupt removal that can damage it; it is only logged. 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. Grandfathering (registering the existing keyboards in the allow-list) is done by the GUI, not by the daemon. The keyboards attached at that moment are registered when you enable the guard in the first-run wizard (and in a one-time migration for an existing install whose guard is on, setup is complete and no attached keyboard is in the allow-list). Enabling the guard later in the USB tab registers nothing. A keyboard that is attached at daemon startup but not registered is treated as unregistered, since it could have been swapped in before a reboot, and is grabbed if another keyboard is available to type on.
§7
The LinuxHealthChecker in roamswitch-core evaluates 27 items and produces a 0–100 score, an S/A/B/C grade (S for 100, A for 85–99, B for 70–84, C below that), and per-item advice. From the GTK dashboard, the CLI (roamswitch status), and MCP (get_security_report), the title, detail, and recommendation of all 27 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 namespace status (Frag Gap mitigation; the Client does not recommend disabling them), Yama ptrace restriction, 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 (including a live EICAR detection test) / fanotify (also checks the guard is actually running and that no events were missed to queue overflow) / entropy monitoring / noexec / installation check of the helper programs (honeytokens and evidence capture) |
| Browser | Firefox / Chrome / Chromium Safe Browsing settings |
| DNS / USB | threat-protection DNS, zero-trust state of the USB bus |
| Containers & Vulnerabilities | Docker socket mount exposure, privileged container (--privileged) audit, container runtime isolation level (runc / gVisor / Kata), known-CVE exposure of the running kernel |
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.
NIST CSF 2.0 / CIS Controls v8 Mapping
Each of the 27 items is tagged with a NIST CSF 2.0 function/category identifier (e.g. PR.DS-01, DE.CM-09), and, only where a confident mapping exists, a CIS Controls v8 control number (e.g. 3.11, 4.1) as well. Ambiguous items are left unmapped rather than forced. This mapping (nistCsf / cisControl) is included in the MCP (get_security_report) output and in the CLI JSON output (roamswitch status --json). It is not shown in the GUI or in the plain CLI output of the Client edition (the Server edition’s roamswitch status --server does show it). It lets operators and auditors who need compliance-framework coverage map RoamSwitch's checks onto existing frameworks mechanically.
§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 almost all of them complete locally. The only tool that can connect to an external host is run_npm_audit_signatures, which is off by default. Evaluating get_security_report and verify_security_findings sends nothing to the LAN, and the gateway is only looked up in the ARP cache and other local information. A ping can go out only in other paths that resolve the gateway's MAC afresh (the daemon's ARP monitoring, emergency restore, Sensor pairing, get_guard_status and the like).
| Tool | Purpose |
|---|---|
| get_security_report | 27-item assessment, score, recommendations |
| verify_security_findings | Re-evaluates items (checkId) from get_security_report against the current state and returns one verdict per item, stillPresent (still applies), resolved (fixed, or not applicable by design) or inconclusive (could not be determined), plus a language-independent reason code. Use it after a fix to confirm that the fix took effect. An item that could not be measured, for example because of missing privileges, a privileged helper that is not connected or an unreachable Docker, is never reported as safe and comes back as inconclusive. Omit checkIds to check every item (up to 50 ids on Mac and 64 on Linux; Linux also accepts isServer). On Mac, host_firewall, network_stealth_mode, gateway_arp_lock, malware_scanning, dns_threat_guard and usb_zero_trust are decided by RoamSwitch's in-app settings, so they reflect those settings and are not a re-measurement of the actual OS state. It is read-only, does not connect to external hosts, and neither repairs anything nor changes any setting. On Linux it sends nothing to the LAN either and only reads the gateway from local caches. On Mac, to look up the gateway's MAC address, it may send an ICMP ping, normally one (a few if the MAC cannot be resolved), to the gateway on the LAN. |
| audit_mcp_configs | Reads the MCP client configuration files (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, Cline, Gemini CLI, plus .mcp.json and similar files in the registered project folders) and flags risky settings. It reports tools auto-approved without per-call confirmation (auto_approve), plaintext http:// servers on a non-loopback host, whether given in the url field or as a launch argument such as --server http://… (remote_plain_http), launch commands that are shell or interpreter one-liners (shell_wrapper), npx / uvx / docker run style launchers that fetch an unpinned package or image (unpinned_package_launcher), docker run with --privileged or with the Docker socket or root filesystem mounted (container_host_access), env entries that make the server load extra code at start-up, such as LD_PRELOAD or --require in NODE_OPTIONS (names matched without regard to case, values never returned; code_loading_env), entries pointing at HexStrike AI, an unauthenticated attack-tool backend (hexstrike), config files writable by group or others (world_writable_config),, Claude Code's enableAllProjectMcpServers switch (auto_trust_project_servers), and a config file that holds both a server that reads text other people wrote (web pages, mail, chat, issues) and a server that can run commands or write files, the pairing indirect prompt injection needs (recognised by well-known package names; it cannot see what a server's tools say; injection_exposure). It takes no arguments, is read-only and sends nothing to the network. It never returns env or header values, arguments or secrets, only server names, a command's basename, a URL host and, for a plain registry name, the package or image name. Files that could not be parsed are listed in unreadableFiles and were not audited. |
| audit_agent_setup | Reads the instruction files and skills you give AI agents (CLAUDE.md, commands, agents and skills under ~/.claude, plus ~/.codex, ~/.cursor/rules, ~/.gemini, ~/.agents, ~/.openclaw, and CLAUDE.md, AGENTS.md, .cursorrules and similar files in the registered project folders) and OpenClaw's openclaw.json, and flags risky content or settings. In instruction and skill files it reports sentences hidden in invisible Unicode tag characters (hidden_tag_characters), zero-width and bidirectional-control characters (invisible_characters), wording that tells an agent to ignore earlier instructions or hide things from the user (override_instruction), fetch-and-run such as curl … | sh (remote_script_exec), decode-and-run (encoded_payload_exec), references to key and credential stores (credential_access) next to an upload or a drop-site service (credential_exfil_shape, exfil_endpoint), persistence set-up (persistence_instruction), compiled code inside a skill (skill_precompiled_code) and files others can write (agent_file_writable). Anything inside an HTML comment is rated higher, because a rendered page hides it. For OpenClaw it reports authentication switched off (openclaw_auth_disabled), a bind that is reachable from the network (openclaw_wide_bind), a token under 24 characters (openclaw_weak_token), a wildcard origin (openclaw_origin_wildcard), dangerously… switches turned on (openclaw_dangerous_flag), a proxy list that trusts everyone (openclaw_broad_trusted_proxy, openclaw_proxy_trust_loosened), DM or group policy open to anyone (openclaw_open_dm), full mDNS (openclaw_mdns_full), suppressed self-check findings (openclaw_audit_suppressed) and loose permissions (openclaw_loose_permissions). Every rule is deterministic: a hit means a person should read that line, not that the file is malicious. It takes no arguments, is read-only and sends nothing to the network. It never returns file contents or secrets (a token is reported by length only), only a rule name, a line number, a URL host and a configuration key path. Files that could not be read are listed in unreadableFiles and were not audited. |
| 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. It never contacts another host, but it sends network requests, so it is disabled by default and requires an opt-in in the 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. On Linux, if the package list could not be read or timed out, it does not report "no findings"; complete becomes false and incompleteReason gives the reason (an unfinished result is not a clean result). On Ubuntu (20.04, 22.04, 24.04, 26.04) it uses Ubuntu's own data, matches by source package name and source version, and checks only CVEs of CVSS 7.0 or higher that Ubuntu has already shipped a fix for, so "no findings" does not mean there are no known CVEs. The result gains ecosystem and release, and each finding carries binary (the installed binary package name); fixes available only through Ubuntu Pro are marked proOnly. Ubuntu derivatives (Linux Mint, Pop!_OS and others) and unsupported releases return complete = false with the reason in incompleteReason, and are not switched to the Debian data. |
| 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. |
| get_vulnerability_scan_history | Returns, for each probe recorded by scan-vulns --confirm, the number of days elapsed since it was last actually run and confirmed (pass_age_days). |
| verify_fim | Re-hashes 23 fixed critical system files, every file directly inside 12 directories (authentication, cron, systemd, SSH configuration and the like), RoamSwitch’s own binaries, and each user’s authorized_keys, and compares them with the baseline to verify tampering and new files. When run as root, the result also has an added field, recentIntrusionContext: for each recent change, the process that made it (account, executable, command) and the SSH login it belongs to (source, login time, authentication method), or why that could not be found. |
| get_file_scan_guard_status | Returns the File Scan Guard's (ClamAV) configuration and the state of the quarantine vault it feeds (a Server Edition tool; it does not report the Client Edition malware guard settings). |
| 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. On the Server Edition each record also carries the time from detection to response in milliseconds (detect_to_receive_ms, receive_to_action_ms, detect_to_action_ms), and the result includes the state of the Falco heartbeat (heartbeat). |
| get_ssh_guard_incidents | Returns the state and history of the SSH brute-force guard: whether it bans or only detects (for example because another tool already guards sshd), the sources banned right now with the time left, and up to the 200 most recent records (a source was banned, was detected without a ban, or a login succeeded after a run of failures from the same source). The user names that were tried are not stored on Linux (only how many) and are returned sanitized, up to 5, on Mac. Mac also returns the recent SSH logins (source, user, method, key fingerprint). Read-only: no MCP tool lifts a ban. On Linux the ban list is readable by root only, so a non-root caller gets only the count. Linux: the Server Edition, and the Client Edition while sshd is running. Mac: Pro only. |
| get_resource_guard_incidents | Returns the Resource Exhaustion / Process Anomaly Guard's history (memory leaks, crash loops) with a confidence tier (this guard belongs to the Server Edition; the Client Edition does not run it, so the result there is always empty). |
| 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). |
Active vulnerability verification also records, for every confirmed probe, how many days have elapsed since it was last actually run and confirmed (pass_age) — distinguishing “this check exists” from “this was actually verified recently.” There is no scheduled runner yet, so this value reflects only how often the scan has actually been run. The same elapsed-days figure is available from the CLI (roamswitch vuln-status), the GUI, and MCP (get_vulnerability_scan_history), so an operator or auditor can see when something was last actually checked rather than assuming a recent check just because the feature exists.
The MCP server opens no network of its own. Only get_sensor_audit_results connects, directly, to the daemon’s Unix socket (/run/roamswitch/roamswitch.sock); every other tool either calls roamswitch-core logic directly or reads state files the daemon wrote (roamswitchkit works the other way round: it is the SDK the CLI and others use to launch roamswitch-mcp as a child process). The exceptions are two tools that are off by default: run_active_vuln_scan sends non-destructive probes to 127.0.0.1, this host itself, and run_npm_audit_signatures starts the npm command, which queries the npm registry (the MCP binary itself contains no HTTP / TLS stack). Evaluating get_security_report and verify_security_findings sends nothing to the LAN, and the gateway is only looked up in the ARP cache and other local information. A ping can go out only in other paths that resolve the gateway's MAC afresh (the daemon's ARP monitoring, emergency restore, Sensor pairing, get_guard_status and the like).
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
By default, 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. Communication that occurs only when an operator or user explicitly enables or triggers it is listed in 9.4. 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-client/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,roamswitchkit(or the CLI and Server crates) pulls in an HTTP-client/TLS stack such asreqwest/ureq/rustls/native-tls/openssl-sys, or if any.rsfile outsideroamswitch-updaterand the files listed in 9.4 usesTcpStream/TcpListener/UdpSocket/to_socket_addrs/lookup_host. The one additional allowance istonic/hyper, permitted only inroamswitch-corefor gRPC over a UNIX domain socket to the eBPF sensor (Tetragon); the same script forbids pairing it with TLS. - 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 and cookies, and a fixed User-Agent as the only header that names the software, 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 | Only in roamswitch-updater and in the four opt-in-feature files listed in 9.4 |
| 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 and the opt-in features in 9.4 (TLS/HTTP is delegated to the system openssl/curl subprocesses); AF_PACKET only when the ARP re-check (§4) sends a single ARP who-has to the gateway (on the LAN). 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 | Two requests per ~24 h: GET https://lafine.net/updates/v1/manifest and its signature manifest.sig (the data files are fetched in addition only when their version is newer). No query string, no cookies, a fixed User-Agent: RoamSwitch-Updater (the only header that names the software), no per-device identifier. 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 |
By default, RoamSwitch for Linux initiates exactly one kind of outbound request, a daily, receive-only check of a signed manifest plus the download of signed detection data when a newer version exists; none of it carries any information about the user or the machine. Unless an operator or user explicitly enables or triggers it, nothing else in the product contacts an external server (9.4). The ping sent into the LAN for neighbour-table pinning and ARP monitoring (#2 in 9.3) goes only to loopback, private, link-local and ULA addresses, and is never sent to a public address.
9.3 Complete list of externally-facing operations
Every default-path operation that could cause a packet to leave the local machine, directly or indirectly (the table below; operations that occur only when an operator or user enables or triggers them are in 9.4):
| # | Operation | Destination | Trigger | What is sent |
|---|---|---|---|---|
| 1 | Daily updater — GET /v1/manifest and its signature; the data files only when their version is newer (the kernel, OS package and language CVE maps are checked about once every 30 days; an Ubuntu device without the Ubuntu CVE map fetches it on the next daily run) | lafine.net/updates/v1 (CDN) | systemd timer: first run 15 minutes after boot, then about every 24 h with a random delay of up to 3 h. Off when updates.enabled = false or roamswitch-update.timer is disabled | a plain HTTPS GET: no query string, no cookies, a fixed User-Agent: RoamSwitch-Updater, 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; also when the user presses the “Update Virus Signatures (freshclam)” button in the GUI | traffic of ClamAV itself (a separate project); RoamSwitch only launches it |
| 2 | ping -c 1 -w 1 -W 1 <gateway IP> (the same kind of single ping to the IPv6 router and to the DNS servers on the LAN) | the LAN default gateway; for the ARP/NDP pinning, also the IPv6 default router and each DNS server listed in resolv.conf (/run/systemd/resolve/resolv.conf under systemd-resolved). only those with a loopback, private, link-local or ULA address are pinged. No packet is sent to a public resolver (for example Quad9 from the threat-protection DNS) | for the gateway, only when its MAC is needed (ARP monitoring and the like) and is in none of the ARP cache, the neighbour table or the 15-second in-memory copy (at most once per 8 s per target). For the IPv6 router and DNS servers, when the gateway ARP lock (gateway_arp_lock_enabled, on by default) re-pins the neighbour table (on switching to a non-trusted network and when the setting is toggled) | a single ICMP echo request (with ping’s standard data). No DNS resolution |
| 3 | launching the system browser (xdg-open) | https://roamswitch.com/linux (product site), https://lafine.net/ (operator site, terms, contact) and https://github.com/lafine1211/roamswitch-support (release notes and support) | 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 by RoamSwitch itself) | threat-protection DNS on an untrusted network (on by default; can be turned off). <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. Afterwards, name resolution on that NIC goes to the selected threat-protection DNS (Quad9 by default) |
| 5 | package-manager updates | the distro’s apt / dnf / zypper / pacman repositories | when the user runs apt upgrade, etc., or clicks the GUI “Upgrade now” button (which runs apt-get / dnf / zypper / pacman via pkexec) | On apt systems RoamSwitch reads local apt-cache policy output and launches the package manager. On dnf systems it runs dnf check-update (when the Updates tab is opened, among other times), which may query the configured repositories (zypper: zypper list-updates, pacman: pacman -Qu); in every case 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 “outbound communication the user explicitly configured”, and its destination is the user’s own VPN server — no data flows to lafine’s infrastructure. The destination of each feature in 9.4 is stated in 9.4.
9.4 Operations that reach outside only when opted in or user-triggered
The table in 9.3 covers what happens by default. What follows are the places in the code that open a socket or hand communication to an external program (curl / openssl / npm) and that run only if an operator or user explicitly enables or triggers them. scripts/audit_no_network.sh permits socket APIs only in five files: event_forward.rs, webhook_guard.rs, active_vuln_scan.rs, web_vuln_scan.rs and sensor_pairing.rs (webhook_guard.rs only resolves the webhook destination’s name with the system resolver, and only when the off-by-default forward_webhook_block_private_addresses is enabled, to check that it is not an internal address; it never connects. In addition, tests/fleet_roundtrip.rs is test-only and connects only to a mock server on 127.0.0.1). The build fails if any other source uses TcpStream and the like. These features add no HTTP/TLS crate either: TLS is left to the system openssl and HTTP to the system curl, as subprocesses.
- Event forwarding (
event_forward.rs) — off by default (forward_enabled = false), and the only destinations are ones the operator configures by hand; there is no built-in destination. The supported sinks are syslog (RFC 5424 or CEF, over UDP / TCP / TLS), a JSON Lines file, and a webhook signed with HMAC-SHA256. TLS syslog goes through the systemopenssl s_clientand the webhook through the systemcurl(secrets such as the URL and signature are passed in a config on standard input, never on the command line); the daemon has no TLS/HTTP crate. Only these fields are sent: host name, event ID, timestamp, source, severity, rule, action, PID, process name and summary (no file contents and no raw execution log). Events that could not be delivered over TCP / TLS / webhook are held in a0600on-disk queue (capped at 20 MB / 72 hours by default) and retried. - Fleet (Business tier, device side only) (
roamswitch-core/src/fleet/) — the device-side code ships in the binary, but a device that is not enrolled sends nothing and does not even resolve the Fleet host name (the worker only reads one local state file). Enrolment happens only when an operator runsroamswitch fleet enroll. The management service is not deployed or released, so there is currently nothing to connect to. The design guarantees present in the code are: (a) the device accepts only organization-signed policy data, verified against the organization public key it pinned at enrolment; a policy that fails verification is not applied. The design has the organization’s signing key held by the customer, not by lafine. (b) There is no remote-command path — the device side has no endpoint, field or code path by which anything could order it to isolate, release, collect or execute. (c) A policy cannot weaken the local floor: disabling a guard or obstructing release / emergency-restore cannot even be expressed in the schema, and the stricter of the local and policy values wins. A policy can also change the event-forwarding destination only to hosts that the local root has allowed (fleet_forwarding_allowed_hosts, empty by default). (d) What a device sends is a minimal state summary — app version, OS family, isolation state (normal / isolated), applied policy version, health score (0–100) and key epoch — plus the device ID and organization ID in the signature headers. It uses the systemcurl(TLS) and only for an enrolled device. - Sensor pairing (
sensor_pairing.rs) — off by default (sensor_pairing_enabled). It only connects directly to the Sensor address an operator enters; there is no built-in destination. It supports TLS 1.3 (via the systemopenssl, with certificate-fingerprint pinning), but a legacy pairing that has no pin falls back to plaintext with a warning. The Sensor is a separate product and is not covered by this document’s Zero Telemetry statements. - Active vulnerability verification (
active_vuln_scan.rs/web_vuln_scan.rs) — disabled by default and requires an opt-in setting. On this endpoint edition the path is limited to probes to127.0.0.1for listening ports already observed on this machine (pinned by the modules’ own tests). - The GUI’s “check for updates” button — only when the user presses the button, and only on apt-based distributions, the GUI makes a single
GETofhttps://lafine.net/apt/dists/stable/main/<arch>/Packageswith the systemcurlto find the latest version (4-second timeout; a public URL carrying no secret, with no identifier or query string added). On dnf, zypper and pacman systems curl is not used; the package manager is queried instead (#5 in 9.3). - npm signature verification —
roamswitch audit-npm-signatures <DIR> --confirmruns only when thenpm_audit_signatures_enabledsetting (default false) is enabled and the user runs it with--confirm;npmitself then contacts registry.npmjs.org.
§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 per-device or per-install identifier, and a fixed
User-Agent: RoamSwitch-Updateras the only header that names the software. 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. - While threat-protection DNS is in use, DNS queries go to an external resolver. Threat-protection DNS (§4.5; on by default, untrusted networks only) points the OS DNS resolver at Quad9 (
9.9.9.9; Cloudflare1.1.1.2can be chosen instead). During that time the queries are sent by the OS, not by RoamSwitch, to that resolver, and RoamSwitch itself sends nothing. Setdns_enabledtofalseto stop it. - Notification history is pruned after 7 days and length-capped — the history of notifications that were sent is stored locally, and entries older than 7 days are deleted on every write. A title is cut to 200 characters and a body to 2000 characters (a desktop notification popup shows at most 1000 characters of body). This history never leaves the machine unless event forwarding (default OFF) is enabled.
- The execution log is local-only too — the process-execution recorder’s log (§5) is, by default, stored only on the machine (
/var/lib/roamswitch/exec_log,0600). Because it contains command-line arguments, it is deleted automatically by retention period (14 days by default) and size (200 MB by default).
§11
| Edition | For | Price | License |
|---|---|---|---|
| Community Edition | Linux personal and in-house business use (no device limit) | Free | Proprietary freeware (bundled EULA) |
| Business (planned) | Fleet management, policy distribution, signed internal apt, SLA for organizations | Paid | Commercial license. Same binary + tier gate |
Once the Business tier becomes available, the policy is to grant Business features free on their own Linux machines to purchasers of macOS RoamSwitch Pro Lifetime. The Business tier is not offered today, and there is no management server.
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 (in preparation. AUR is not accepting new accounts, so for now build it with makepkg from https://lafine.net/linux/dl/PKGBUILD; the tarball from lafine.net/linux/dl is verified by sha256 and a detached signature (validpgpkeys)) |
| tarball | .tar.gz + .sha256 + .asc (detached signature by the repository signing key) for x86_64 / aarch64 at https://lafine.net/linux/dl/. The source repository is private, so this, not GitHub Releases, is the public download location |
- 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: 15 minutes after boot, then every 24 hours, with a random delay of up to 3 hours). By default it is the only component that reaches an external distribution URL (apart from the opt-in features in 9.4). 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). The other data is fetched only when the manifest shows a newer version (the active-scan CVE map and the popular-npm-package list). The kernel, OS-package and language-ecosystem CVE maps are fetched only when about 30 days have passed since the last refresh. The Ubuntu CVE map is fetched only on a supported Ubuntu release, and a device that does not have it yet fetches it on the next daily run without waiting for the 30 days. Each is installed only after its signature is verified.
- When the manifest version is newer, the web application detection rules (§5) are fetched too. The SHA-256 and the Ed25519 signature are verified, and the file is also checked to be readable as rules before it is installed atomically. An unreadable file is never installed and the rules in force are not replaced. No new destination is added; it rides on the same manifest fetch. On the Server Edition it follows
cve_kernel_map_updates_enabled: with it off, there is no network contact at all. - 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 lastError in updates.json and the process still exits 0, so a timer run never generates a systemd failure notification. The whole mechanism is disabled by "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, Fedora / RHEL family, openSUSE, Arch family (the AUR package is in preparation; install from the PKGBUILD), Raspberry Pi OS 64-bit (Bookworm), Ubuntu for Raspberry Pi
- Architecture: x86_64 / aarch64
- Required:
systemd,nftables,iproute2(ip neigh, the ARP/NDP lock),conntrackandlibnotify(desktop notifications); everything exceptsystemdis pulled in automatically by the.deb/.rpmdependencies - Optional:
clamav(malware scanning),nmcli(Wi-Fi encryption detection),iw(fallback wireless information),bluez(Bluetooth guard),systemd-resolved(threat-protection DNS configuration),lsof(fallback listening-port detection),nmap(supplementary active vulnerability checks),wireguard-tools(the WireGuard backend of the VPN tunnel + kill-switch, §4),tailscale(its Tailscale backend, §4),polkit/pkexec(running an upgrade, an uninstall or a daemon restart 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"]; by default only Unix sockets are used) | none (no TCP by default; the opt-in Sensor pairing is the exception) |
| 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 and of the updater's signed manifest and data such as the threat feed (also used by the opt-in Sensor pairing) | 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 to open links such as “Open release notes”, the EULA and the support pages | the process itself does not communicate |
| GTK3 / ksni (app only) | GUI and tray | none |
| serde_yaml | reads /etc/roamswitch/guard.yaml (core) | none |
| toml | parses Cargo.lock / poetry.lock (language package CVE scan, core) | none |
| md-5 | hash for the Link Guard's JA3 fingerprints (not used for authentication or integrity, core) | none |
| bs58 | Base58 check of crypto-asset private keys (secret detection, core) | none |
| rand | generates the device key of the opt-in Sensor pairing (core) | none |
| tonic / tonic-prost / prost / tower / hyper-util | gRPC to the Tetragon sensor; Unix domain socket only, no TLS (core) | none (Unix socket only) |
| flate2 | gzip decompression of the RHEL-family package CVE map (updater only; pure Rust) | none (decompression only) |
| inotify | watches access to the honeytoken decoys (roamswitch-honeytokens) | none |
| aya | loader of the experimental XDP boot gate (roamswitch-xdp-gate, linked by the daemon) | none (bpf() / netlink only) |
| urlencoding / gdk / gdk-pixbuf / glib / pango | URL decoding in the GUI and the surrounding GTK3 crates (app only) | none |
| ureq + rustls (roamswitch-updater only) | the daily updater's HTTPS GETs (manifest, signature, and any data that changed) | the only HTTP/TLS stack in the tree; confined to the updater binary |
None of roamswitch-core, roamswitch-daemon, roamswitch-app, roamswitch-mcp or roamswitchkit contains an HTTP client that can reach the internet, a TLS stack, or a telemetry SDK. The tonic / hyper pair in roamswitch-core serves gRPC to Tetragon over a Unix domain socket only, has no TLS and never touches the network. 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 (open / balanced / lockdown)
chain input {
type filter hook input priority 0; policy <accept|drop>;
ct state established,related accept
iif "lo" accept
ip protocol icmp accept # open is policy accept with no rules; balanced / lockdown are policy drop (ICMP / ICMPv6 are allowed in balanced only)
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table inet roamswitch { # During Air-Gap the same table is rebuilt: policy drop on input and output, only lo is allowed (established connections are not let through either)
chain input { type filter hook input priority -100; policy drop; iif "lo" accept }
chain output { type filter hook output priority -100; policy drop; oif "lo" accept }
}
table inet roamswitch_linkguard { # §4.5 — independent of the profile table
chain output {
type filter hook output priority 0; policy accept;
udp dport 443 reject with icmpx type port-unreachable # only when block_quic is enabled (off by default)
meta l4proto udp udp dport 53 queue num 92 bypass
meta l4proto tcp tcp dport 53 queue num 92 bypass
meta l4proto tcp tcp dport { 80, 443 } ct state new,established ct original packets 1-14 queue num 92 bypass # only the first 14 packets of each connection are queued for inspection
}
}
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_portscan { # port-scan detection and blocking (on by default)
chain block { type filter hook input priority -200; policy accept; ip saddr @blocked_ips ct state new log … drop }
chain detect { type filter hook input priority -100; policy accept; iifname != "lo" ct state new tcp flags syn log … }
}
table inet roamswitch_egress_block { # outbound blocklist of malicious IPs (on by default)
chain output { type filter hook output priority -150; policy accept; ip daddr { … } log … drop }
chain forward { type filter hook forward priority -150; policy accept; ip daddr { … } log … drop }
}
table inet roamswitch_docker_guard { # countermeasure for the Docker published-port firewall bypass (only with Docker's native nftables backend)
chain forward { type filter hook forward priority -10; policy accept; … iifname { … } drop }
}
table inet roamswitch_isolate { # per-process containment after an eBPF detection (by cgroup / UID)
chain process_block { type filter hook output priority -100; policy accept; … 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-daem)" -a -i
# → No output = zero TCP/UDP internet connections
sudo lsof -p "$(pgrep -x roamswitch-daem)" -a -U | grep roamswitch
# → Only /run/roamswitch/roamswitch.sock and events.sock (receives eBPF detection events) (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-(daem|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
# → Only HTTPS GETs to lafine.net/updates/v1 (manifest, signature, and data that changed), then it exits. If ClamAV is installed, freshclam also contacts the ClamAV mirrors.
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-daem)" 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)
Most of the defense mechanisms of RoamSwitch (Air-Gap enforcement, self-healing after firewall clobbering, ransomware canary tampering, /tmp noexec, Yama LSM, homograph phishing interception and more) can be reproduced on your own machine with the Docker test suite in the public support repository (roamswitch-support). The suite only touches its own network namespace and files inside the container, so the host network and the host files are not affected. It does run with --privileged, however, so kernel-wide settings that the container shares with the host (such as Yama's ptrace_scope and suid_dumpable) are changed on the host as well, and removing the container does not undo them. Run it in a disposable environment.
# 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 runs and judges the checks inside an isolated network and mount namespace. The public suite consists of pentest.sh (the seven PENT-1 to 7 scenarios) and selftest.sh (destructive daemon self-tests for Air-Gap, self-healing, canaries, burst detection and the quarantine vault). The IDs in the tables below are serial numbers from the verification records and do not map one to one onto checks in the scripts. BadUSB (SP-6), the Link Guard modes (SP-7 family), ARP pinning and the VPN kill-switches (SP-8 to 10), the individual kernel-hardening checks (SP-11 family) and PENT-8 and 9 are not part of the public suite; they were confirmed by other means (a real host, separate network namespaces, manually injected events).
(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 | Stop the daemon during Air-Gap (selftest.sh uses kill, i.e. SIGTERM) and restart it | 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) Attack-simulation self-check items (PENT-1 to 7 in the public Docker suite, PENT-8 and 9 from separate manual 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) | In the v1.1.0 measurement, rejected immediately with Operation not permitted by user.max_user_namespaces = 0. The current Client Edition does not deny user namespaces by default (auto_enable_userns_restriction in guard.yaml is off; the Server Edition always applies it) |
| 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 |
On the kinds of checks, of the items in the table above, PENT-2/2b (noexec), PENT-3/3b (Yama, core dumps) and PENT-5/5b/5c (rp_filter, SYN cookies, ICMP redirects) confirm that a standard kernel or mount feature that RoamSwitch configures is in effect. PENT-8 (denying user namespaces) is of the same kind, but it is a record from v1.1.0 and the current Client Edition does not apply it by default. The items that attack RoamSwitch's own detection and containment logic are PENT-1/1b/1c (Air-Gap and self-healing), PENT-4/4b (canaries), PENT-6/6b (Link Guard), PENT-7/7b (secret detection) and PENT-9 (freeze and containment after an eBPF detection). PENT-2c confirms that ordinary work is not broken.
(7) Self-Check Summary (Standalone Hardened vs. eBPF Enhanced)
Summary of results from the full attack-simulation self-check executed in an isolated Docker environment (Target: Ubuntu 24.04 with RoamSwitch Client Edition; Attacker: Nmap / curl / hping3). The table below is a measurement of Client v1.1.0 taken on 2026-09-06. On 2026-09-25 a fixed build made from the 1.10 source (not a public release) passed 11 of 11 items of the client suite (see audit/RESULTS-PENTEST-2026-09-25.ja.md in the public repository).
| 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 (v1.1.0 measurement; unshare blocked where user.max_user_namespaces = 0 is set. Off by default in the current Client Edition) | 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 | it is isolated in dedicated tables (inet roamswitch and the other roamswitch_* tables), but global 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, on by default) covers the resolver path |
The daily updater's freshness depends on the release cadence when enabled is false in updates.json | 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 |