netscope
Per-app network scoping for Linux. Select an app once. Launch it normally.
Alpha. Linux only. Kernel 5.13 or newer, cgroup v2, nftables, systemd.
Why
Most Linux VPN and proxy tools route the whole machine or require a wrapper. netscope gives one application its own cgroup identity and applies a filter or a routing mark to that identity, so the app starts the way you already start it.
What it does
- Normally launched apps.
fanotifycatchesexecand places the process in its profile cgroup before the first socket. - Running apps by name. A
--processtarget attaches a process bycommor executable basename, with no install path and no wrapper. A new Wine/Proton launch is caught by the loader hold; an already-running process attaches on the poll, and its existing sockets are marked so they follow the profile. - Per-app cgroup identity. nftables matches
socket cgroupv2, not a process name that can be spoofed. - Shared process scopes. Two tools can name the same target and share one profile and cgroup, each with its own policy; a scope carries one route policy and any number of filters. The last consumer to close removes the scope.
- Blackhole fallback while active. Route profiles use a dedicated table with a blackhole default. If the tunnel interface is down, traffic fails rather than leaking. Daemon death or table removal is fail-open; the docs say so.
Quick start
sudo netscope --uid "$(id -u)" add blocked --exe /usr/bin/curl --filter drop
curl --max-time 3 https://example.com
sudo netscope remove blocked
Next: install, quickstart, or how it works.
Install
netscope is alpha software for Linux. It needs a unified cgroup v2 hierarchy, nftables, iproute2, systemd, and kernel 5.13 or newer for the fanotify exec hook.
Requirements
| Requirement | Check | Notes |
|---|---|---|
| Linux kernel 5.13+ | uname -r | 5.13 is the first kernel with FAN_OPEN_EXEC_PERM. Older kernels cannot hold an exec until attachment. |
| cgroup v2 (unified) | stat -fc %T /sys/fs/cgroup prints cgroup2fs | Hybrid or v1-only systems are unsupported. |
| nftables | nft --version | Used for the per-profile table. |
| iproute2 | ip -V | Used for policy routing rules and the route-mode blackhole. |
| systemd | systemctl --version | Runs and supervises netscoped. |
| zstd | zstd --version | Only needed to extract the release tarball. |
Check the kernel and cgroup mode before installing:
uname -r
stat -fc %T /sys/fs/cgroup
cgroup2fs is required. If the command prints tmpfs, the system is still on cgroup v1.
Choose a channel
| Channel | Installs | Unit and config | Best for |
|---|---|---|---|
AUR netscope-bin | Binaries | Yes, via the package | Arch Linux and CachyOS |
| Release tarball | Binaries, unit, sysusers, config, install scripts | Yes, via install.sh | Any supported distro |
| Source build | Binaries from target/release | Yes, via install.sh | Development and ARM builds |
cargo install --git | Binaries only | No | Advanced users who already have the system files |
The unit, the netscope group definition, and /etc/netscope/config.toml are not installed by Cargo. The systemd unit executes /usr/bin/netscoped, so a Cargo-only install does not start as a service without extra work.
AUR (Arch Linux, CachyOS)
After the package is published:
yay -S netscope-bin
sudo systemctl enable --now netscoped
sudo usermod -aG netscope "$USER"
Log out and back in so the group membership applies. paru -S netscope-bin is equivalent.
Release tarball
Download netscope-v0.3.0-x86_64-linux.tar.zst from the v0.3.0 release. Verify it against the published checksum:
curl -fsSLO https://github.com/Qiiks/netscope/releases/download/v0.3.0/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
Then extract and run the included installer:
tar --zstd -xf netscope-v0.3.0-x86_64-linux.tar.zst
cd netscope-v0.3.0-x86_64-linux
sudo ./install.sh
sudo usermod -aG netscope "$USER"
Preview every change without touching the system with sudo ./install.sh --dry-run.
install.sh checks the architecture, kernel, cgroup mode, and required tools, then:
- installs
netscope,netscoped, andnetscope-guito/usr/bin; - installs the
netscope-guidesktop entry, so the GUI appears in your application menu; - installs the unit to
/usr/lib/systemd/system/netscoped.service; - installs the sysusers entry to
/usr/lib/sysusers.d/netscope.confand creates thenetscopegroup; - installs
/etc/netscope/config.tomlonly if it does not already exist; - enables and restarts
netscoped.
Log out and back in after usermod.
Build from source
cargo build --release --workspace
sudo ./install.sh
sudo usermod -aG netscope "$USER"
install.sh prefers binaries in the repository root, then target/release, then target/debug. It finds the unit and config in packaging/ when they are not next to the script.
Cargo
cargo install --git https://github.com/Qiiks/netscope --tag v0.3.0 --locked netscope-cli netscope-daemon
This installs the two binaries into ~/.cargo/bin and nothing else. To run the service, the unit expects them at /usr/bin:
sudo install -Dm755 ~/.cargo/bin/netscope /usr/bin/netscope
sudo install -Dm755 ~/.cargo/bin/netscoped /usr/bin/netscoped
Then install netscoped.service, netscope.conf, and config.toml from the tarball or repository as install.sh does, or skip the service and run sudo netscoped --foreground manually for development.
Verify
netscope --version
sudo netscope doctor
doctor exits 0 when every check passes and exits 1 when any check fails. It does not require any profile to exist. A non-root run fails the root check, so use sudo netscope doctor for a clean and complete report. If it reports a failure, see Troubleshooting.
Run a first profile from Quick start.
Upgrade
- AUR:
yay -Syu netscope-bin(orparu -Syu netscope-bin), thensudo systemctl restart netscoped. - Tarball or source: unpack or rebuild, then
sudo ./install.shagain. The installer stops the old daemon, replaces the binaries and unit, preserves/etc/netscope/config.tomland/var/lib/netscope/state.json, and restarts the service. - Cargo: rerun the
cargo installcommand and copy the binaries to/usr/bin, thensudo systemctl restart netscoped.
Profiles live in /var/lib/netscope/state.json and survive upgrades. The state format has a version field; a newer daemon rejects a state it cannot read instead of guessing.
Uninstall
- AUR:
yay -Rns netscope-bin(orparu -Rns netscope-bin). - Tarball or source:
sudo ./uninstall.shfrom the extracted release or repository.
uninstall.sh stops and disables the service, runs netscope cleanup --all, removes the binaries, unit, and sysusers entry, and removes the netscope group. It preserves /etc/netscope and /var/lib/netscope unless you pass --purge:
sudo ./uninstall.sh --purge
Preview with sudo ./uninstall.sh --dry-run.
Do not run uninstall.sh for an AUR install; it would delete files owned by the package manager. Remove the package instead, then optionally sudo rm -rf /etc/netscope /var/lib/netscope to clear state.
If a process was still attached when the daemon exited, remove the leftover cgroup or reboot. cleanup --all removes netscope’s profile cgroups when it can.
Quick start
This walkthrough blocks one application’s outbound traffic, checks that the block applies only to that application, and then removes the profile. It assumes netscope is installed and netscoped is running; see Install.
All examples use sudo netscope --uid "$(id -u)". The $(id -u) is expanded by your shell before sudo, so it is your user ID, not root’s. If your user is in the netscope group and you have logged in again, you can omit both sudo and --uid.
1. Check the daemon
sudo systemctl status netscoped
netscope status
netscope status prints the daemon version, kernel, cgroup root, and profile table. If it exits 3, the daemon is not reachable:
netscope: daemon not running; start with: sudo systemctl start netscoped
Start it with sudo systemctl start netscoped, then retry.
2. Block one application
sudo netscope --uid "$(id -u)" add blocked --exe /usr/bin/curl --filter drop
This creates a profile named blocked for /usr/bin/curl with a filter whose default action is drop. The executable path is resolved from your current directory, so relative paths work, but an absolute path is clearer.
Test it:
curl --max-time 3 https://example.com
The request fails because every non-loopback destination from curl is dropped. Loopback is still accepted in drop mode; to see it working, run a local server in another terminal and fetch it:
python3 -m http.server 8080
curl --max-time 3 http://127.0.0.1:8080/
3. Inspect the profile
netscope list
sudo netscope status
list shows NAME, MODE, ENABLED, TARGET, ATTACHED, and PIDS for every profile, including disabled ones. status adds the daemon and cgroup header. A process that was started before the profile was added appears as late; a native app caught by the exec hook appears as pre-exec, and a Wine/Proton process caught opening the selected PE appears as pre-open.
Short-lived commands are usually gone before you look, so PIDS may be empty even though the block applied.
4. Add a narrow exception
Replace the profile with one that allows a single address:
sudo netscope --uid "$(id -u)" remove blocked
sudo netscope --uid "$(id -u)" add blocked --exe /usr/bin/curl --filter drop --allow-cidr 1.1.1.1/32
curl --max-time 5 https://1.1.1.1/
With this profile, https://1.1.1.1/ is allowed and every other destination is still dropped. Exceptions are destination CIDRs, not host names or ports. If the TLS certificate check fails on your system, use curl -k for this connectivity test only.
5. Select an app at launch
The point of the pre-exec path is that nothing wraps the application. For example:
sudo netscope --uid "$(id -u)" add browser --exe /usr/bin/firefox --filter allow --deny-cidr 192.168.0.0/16
firefox
Start firefox normally, from the desktop or a shell. The daemon catches the exec, places the process in the netscope/browser cgroup before its first socket, and nftables matches the cgroup. In this example the browser keeps internet access but cannot reach the 192.168.0.0/16 LAN. If the app is already running when you add the profile, it is attached on the next poll and its existing sockets are marked.
6. Route instead of filter
Route mode requires a tunnel interface that already exists, such as tun0 from WireGuard or OpenVPN:
sudo netscope --uid "$(id -u)" add routed --exe /usr/bin/curl --route tun0
curl --max-time 5 https://example.com
Traffic from curl is marked and looked up in netscope’s dedicated routing table. LAN destinations bypass the tunnel by default. To send LAN traffic through the tunnel too, add --include-lan:
sudo netscope --uid "$(id -u)" remove routed
sudo netscope --uid "$(id -u)" add routed --exe /usr/bin/curl --route tun0 --include-lan
See Route mode for the blackhole fallback and IPv6 behavior.
7. Route a Windows game through Wine or Proton
Point --wine at the .exe itself, not at a wine binary or a launcher:
sudo netscope --uid "$(id -u)" add game --wine /games/Game/Game.exe --route tun0
The path must be absolute, and the file must start with MZ — the DOS/PE header. Launch the game the way you normally do. When Wine opens the PE, the daemon holds the open, finds the Wine process whose command line names the PE, moves it into netscope/game, and then allows it. netscope status shows pre-open for that profile.
For a Steam Proton game, select the .exe the loader actually opens, usually under steamapps/common/<Game>/. A copy inside the Proton prefix (steamapps/compatdata/<appid>/pfx/drive_c/...) is a different file; select that path if the game is launched from it. --steam <appid> remains available when you prefer to match the Steam app instead.
8. Attach a running app by name
If the app is already running, or you do not want to store its install path, use --process:
sudo netscope --uid "$(id -u)" add game --process Overwatch.exe --route tun0
No path and no wrapper are involved. The daemon matches the process comm or its executable basename, and any argv basename of a Wine loader, case-insensitively. A new Wine/Proton launch is caught by the loader hold before its first socket; a process that was already running attaches on the next poll and is reported as late, with its existing sockets marked so their next packets follow the profile without a restart.
9. Share one process between two tools
When two programs need the same app, name it once per consumer with open; a second add for the same target is refused:
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch --process Overwatch.exe --route tun0
sudo netscope --uid "$(id -u)" open --consumer dropship --process Overwatch.exe --filter allow \
--deny-cidr 64.224.26.0/23
Both consumers get the same profile and cgroup. Each owns its own policy; the last netscope close <profile> --consumer <id> removes the scope. See Shared scopes.
10. Run diagnostics
sudo netscope doctor
doctor exits 0 when every check passes and 1 when any fails. Run it as root (sudo netscope doctor); without root the root check fails and doctor exits 1. Add --json for machine-readable output. If it fails, see Troubleshooting.
11. Clean up
sudo netscope remove blocked
sudo netscope remove browser
sudo netscope remove routed
sudo netscope remove game
remove deletes the profile’s nftables table, routing rules, routes, and cgroup. Removing a profile does not stop the application; traffic returns to normal routing immediately.
How it works
netscope gives one application a cgroup identity and writes nftables rules that match that identity. The application is launched normally; there is no wrapper, LD_PRELOAD, or proxy setting.
The pipeline
- Add a profile. The CLI canonicalizes
--exeand sends its path to the daemon. The daemon resolves the path to a device and inode and stores that identity with the profile. A--wineprofile does the same for a Windows PE, after checking that the file starts withMZ. A--processprofile stores only the process name. A Steam profile stores the app ID instead. - Catch the next exec or open.
fanotifywatches forexecof the selected native inode, or for the first open of the selected PE by a Wine/Proton loader. The kernel holds the operation until the daemon answers, which gives the daemon time to create/sys/fs/cgroup/netscope/<profile>and move the new process into it. A name target holds the Wine/Proton loader binaries and matches the loader’s launcher ancestry or its command line against the target name, so a new game launch is also caught before its first socket. An already-running process is attached by the poll. - Match on the cgroup or the profile mark. nftables rules use
socket cgroupv2for processes moved before their first socket. A process attached after it already held sockets has those sockets marked with the profile’s firewall mark, and a secondsocket markrule matches them. A process cannot escape the cgroup match by renaming itself or by being re-parented. - Filter or mark. A filter profile accepts or drops matching packets. A route profile sets a firewall mark on them.
- Route by mark. For route profiles, an
ip rulesends marked packets to a dedicated routing table containing the tunnel route and a higher-metric blackhole default.
The process stays in the profile cgroup for its lifetime, and children inherit it. That is why launching a game launcher that later starts the game still works: the launcher’s children remain in the same cgroup.
Profile identity
Each profile gets a bounded set of host identifiers:
| Value | Base | Per-profile | Limit |
|---|---|---|---|
| Firewall mark | 0x4e53 | + index | 32 profiles |
| Routing table | 20000 | + index | 32 profiles |
| Rule priority | 1000 | + index × 32 | 32 profiles |
| nftables table | — | netscope_<id> | one per profile |
| cgroup | /sys/fs/cgroup/netscope | /<id> | one per profile |
A profile ID is 1–32 characters: lowercase ASCII letters, digits, and -, starting with a letter or digit. The nftables table name replaces - with _, so my-game becomes netscope_my_game.
Pre-exec attachment
For native ELF targets the primary path is pre-exec:
- The daemon registers the profile’s inode with a fanotify group that has permission events enabled.
- When the kernel reports
FAN_OPEN_EXEC_PERMfor that inode, the daemon reads the PID, verifies the process still matches, creates the cgroup, writes the thread-group ID tocgroup.procs, and allows the exec. - The daemon records the process start time before and after the move. If the PID was reused during the operation, it restores the original cgroup and refuses the attach rather than capturing an unrelated process.
Kernel 5.13 or newer is required because earlier kernels cannot hold an exec for this decision. Root is required to create the cgroup and to open the fanotify permission group.
Wine and Proton targets
A Windows program does not exec a new image under Wine. The Wine loader maps the PE and runs its code; the shared wineserver may open the PE on the loader’s behalf. There is no exec of the PE to hold, so --wine uses a different mark:
- The daemon marks the PE inode with
FAN_OPEN_PERM, the permission event for an ordinary open, instead ofFAN_OPEN_EXEC_PERM. - When any process opens the marked PE, the kernel holds the open. If the opener is a Wine process whose command line names the PE, the daemon attaches that process. If the opener is
wineserveror another Wine helper, the daemon scans for a Wine process whose command line names the PE and attaches that one instead. It never attacheswineserver. - If no matching process exists yet, the daemon allows the open and the poll fallback attaches the process later.
- The game process has not run game code until the PE open is allowed, so the game’s sockets are created after the move.
statusreports the source aspre-open.
A Wine process is an executable whose basename is wine, wine64, wine-preloader, or wine64-preloader, or whose basename contains wine-preloader. Only a Wine process whose command line names the selected PE is attached. Any other program that opens the same PE — a file manager, a scanner, a copy — is allowed and left unscoped, because for those processes the PE is just a data file.
The mark is on the file rather than on an exec, so it also catches a load by a loader that was already running, and a second launch of the same game. Select the PE path that Wine actually opens. For Proton that is usually the .exe under steamapps/common; if the game is launched from a copy inside the prefix, select that copy instead. --steam <appid> remains available for matching a Steam app by id.
Wine itself is not installed by netscope and is not required to configure a profile. Adding the profile fails if the path is not absolute, cannot be canonicalized, is not a regular file, or does not start with MZ.
Name targets
A --process <name> target needs no install path. It covers both a new launch
and an already-running app:
- The daemon scans
/procon the poll interval and reads each process’scommand command line. - A process matches when its
commor its executable (argv[0]) basename equals the name. A Wine loader also matches when any argv token’s basename equals it, because Wine carries the PE there. Matching is case-insensitive and drops a trailing.exe, soOverwatch,Overwatch.exe, andoverwatch.exeselect the same process;C:\Games\Overwatch.exematches as a Wine argv token, but a path is not an accepted target value. - For a new Wine/Proton launch, the daemon holds the loader binaries with
FAN_OPEN_EXEC_PERM. If the loader’s launcher ancestry or the loader’s own command line names the target, the loader is moved into the cgroup before it runs, and the game inherits it. The daemon then learns the PE path from that command line and holds the PE open, so the next launch is caught even if the loader path changes. - An already-running process is attached on the next poll. Sockets it already held are marked with the profile’s firewall mark, so their next packets follow the profile too.
statusreports the target asname:<name>and the attach source aspre-execfor a loader-held launch orlatefor a running process.
Name targets cannot distinguish two processes with the same name; both are
attached. The loader hold only covers Wine/Proton binaries the daemon can find
and whose launcher ancestry or command line names the target; a custom build
outside the scanned directories, or a launcher that does not name the game,
falls back to late attach. The PE hold is learned only from an absolute, Z:\, or C:\
path in that command line.
Late attachment (poll fallback)
If fanotify cannot be used — for example because the kernel or a security policy refuses the permission group — the daemon scans /proc on an interval (poll_interval_ms, default 1000 ms). It attaches matching processes and their children after the fact. For a native target it matches the executable path. For a Wine target it requires a Wine process and matches the configured PE path against the process command line, accepting a Windows-style Z:\... path and the C:\... form that maps under /drive_c/. Name targets use this path when the process was already running.
Late attachment covers sockets that already exist. After moving the process
into its cgroup, the daemon walks /proc/<pid>/fd, duplicates each descriptor
with pidfd_getfd, and sets SO_MARK to the profile’s mark on every IPv4 and
IPv6 socket it finds. Changing the mark resets the kernel’s cached destination,
so the next packet is routed through the profile’s table. The nftables chain
matches those sockets with socket mark in addition to socket cgroupv2.
Name targets use this path for a process that was already running; a new Wine
launch is caught by the loader hold instead.
This needs CAP_NET_ADMIN and the ability to inspect the target process, which
the root daemon has. If a descriptor cannot be reached, the attach is reported
with a mark profile=... error=... log line and that socket keeps its original
route. A daemon restart marks existing sockets again on the next poll.
The daemon reports a late attach through status, and the CLI prints a
warning when the target was already running:
existing sockets marked; restart is not required
Removing a profile clears the mark from sockets that still carry it, so a later profile reusing the same mark cannot inherit them. A daemon restart keeps the marks that are already set and re-marks the target on the next poll.
Background loops
Three loops keep a profile current:
| Loop | Interval | Work |
|---|---|---|
| Poll | poll_interval_ms, default 1000 ms | Scan /proc and attach matching processes that the exec or open hook did not catch. |
| Refresh | refresh_interval_secs, default 5 s | Re-resolve each native or Wine target’s device and inode, move its fanotify mark if the file changed, then attach matching processes. |
| Health | 10 s, fixed | Verify each enabled profile’s cgroup, nftables table, policy rule, routes, and fanotify mark; recreate anything missing. |
The health loop is what repairs a table or rule deleted by hand, within about ten seconds. netscope refresh does not run a repair pass; it re-stats targets and attaches processes, which is useful after a binary is replaced.
Because the health loop recreates missing state, removing a profile with netscope remove is the supported way to stop scoping. Deleting its nftables table by hand while the profile is enabled just makes the daemon recreate it.
Filter evaluation
A profile’s nftables table hooks output in the inet family. Rules are evaluated in order:
--filter drop: drop IPv4 traffic except loopback127.0.0.0/8and the--allow-cidrdestinations; drop IPv6 except::1/128and allowed IPv6 destinations; drop non-IP traffic too. Loopback is always allowed.--filter allow: drop each--deny-cidr; the chain policy accepts everything else. At least one deny CIDR is required, otherwise the profile would do nothing.
Only packets with a socket in the profile’s cgroup or carrying the profile’s mark are matched. See Filter mode.
A shared profile can carry several filter policies, one per consumer. They are emitted before the route mark. A drop from any consumer is terminal; an accepted packet falls through to the route mark, so a filter never silently replaces a route. See Shared scopes.
Route evaluation
A route profile’s table accepts loopback, optionally accepts LAN destinations, optionally drops IPv6, then sets the profile’s mark. A policy rule sends marked packets to the profile’s table. That table has:
- a real default route through the configured interface at metric 100;
- a blackhole default at metric 1000.
When the interface is up, the real route wins. When it is down, the blackhole matches instead of falling back to the main table. Each route exception adds an ip rule to main at a lower priority number than the profile’s mark rule, so it is evaluated first and that destination stays direct while everything else uses the profile’s table. See Route mode for the exact fail-open cases.
What netscope does not do
- No kernel module and no eBPF program. Everything is cgroup v2, fanotify, nftables, and policy routing.
- No packet capture, no DNS rewriting, no proxy, no remote API, and no web UI.
- No system-wide mode: netscope scopes selected applications, not the whole machine.
- No coverage for traffic forwarded on behalf of another host, kernel-generated packets without a local socket, or sockets that cannot be inspected by the root daemon.
Next
Architecture & Decisions
Netscope provides per-process network filtering and routing on Linux without requiring application wrappers (LD_PRELOAD, network namespaces, or launch prefixes). This document records the core architectural decisions, the alternatives considered, and the engineering rationale behind them.
1. Architectural Decisions and Rationale
| Area | Decision | Considered & Rejected | Engineering Rationale |
|---|---|---|---|
| Kernel Primitive | Standard upstream primitives: cgroup v2 + nftables + fanotify. | Custom Linux kernel module (DKMS / eBPF LSM / out-of-tree driver). | While Windows requires a WFP callout kernel driver, building a Linux kernel module creates massive maintenance friction, breaks across kernel upgrades, and requires Secure Boot MOK signing. Combining fanotify (FAN_OPEN_EXEC_PERM) for pre-socket interception, cgroup v2 for hierarchy inheritance, and nftables socket cgroupv2 matches provides full WFP-parity using standard kernel features (kernel >= 5.13). |
| Shared Scopes | Unified shared cgroups with multi-consumer policy chains (<consumer>:filter, <consumer>:route). | Isolated per-tool cgroups (e.g. dropship-game vs exitlink-790). | In Linux cgroup v2, a process can belong to only one leaf cgroup. If tool A and tool B each create their own cgroup, attaching a process to tool B silently pulls it out of tool A’s cgroup, causing tool A’s nftables selectors to match zero packets. Netscope creates one authoritative cgroup per process and evaluates multiple consumers’ policies on independent chains. |
| IPC & Lifecycle | Unix domain control socket (/run/netscope/control.sock) speaking newline-delimited JSON with Hold::Peer. | CLI-only execution with persistent marker files. | CLI commands create persistent state. If a client app (like a game launcher, proxy, or firewall UI) crashes, persistent claims remain orphaned. Using a stream socket with Hold::Peer ties the scope and rules to the client’s socket file descriptor: when the client process exits or crashes, the kernel closes the socket, and netscoped automatically cleans up the policy and cgroup. |
| Late Socket Attach | Process socket duplication via pidfd_getfd and direct SO_MARK. | Requiring application restarts, or relying purely on socket cgroupv2. | In Linux, sockets created before a process is moved into a cgroup retain their pre-existing socket attributes. To scope already-running applications without requiring a restart, the daemon iterates /proc/<pid>/fd, calls pidfd_getfd to duplicate datagram/stream sockets, and applies SO_MARK. A dedicated socket mark rule matches these existing sockets. |
| Protocol Scoping | Protocol-scoped marking (udp_only: bool) and source-port pins (--except-sport). | Uniform socket-marking across all protocols. | Marking all sockets in a cgroup inadvertently pulls TCP authentication/login sockets into UDP-only tunnels (like gaming VPNs), causing game auth disconnections. Protocol scoping restricts marking to SOCK_DGRAM on UDP-only routes, while --except-sport pins active match ports so existing established flows are not disrupted mid-game. |
| Client SDK | Zero-privilege netscope-client published to crates.io. | Bundling root code into application binaries, or requiring raw subprocess execs. | GUI applications (GPUI, egui, Electron) should never run with root privileges or CAP_NET_ADMIN. netscope-client is a safe, lightweight IPC crate with zero system dependencies that communicates with netscoped over the control socket. |
2. Subsystem Architecture
┌────────────────────────────────────────────────────────────────────────┐
│ Applications │
│ Dropship (Filter Consumer) ExitLink (Route Consumer) │
└───────────────────┬───────────────────────────────────┬────────────────┘
│ │
│ Unix Domain Socket (NDJSON) │
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ netscoped (System Daemon, Root) │
│ │
│ ┌─────────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Process Watcher │ │ Scope Manager │ │ nftables Engine │ │
│ │ · fanotify hold │ │ · cgroup v2 │ │ · inet tables │ │
│ │ · Wine PE loader │ │ · multi-holds │ │ · policy chains │ │
│ │ · pidfd_getfd mark │ │ · generations │ │ · priority sort │ │
│ └─────────────────────┘ └─────────────────┘ └──────────────────┘ │
└───────────────────────────────────┬────────────────────────────────────┘
│ Kernel Configuration
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Linux Kernel │
│ │
│ /sys/fs/cgroup/netscope/<profile> (Single unified game cgroup) │
│ │ │
│ ▼ │
│ nftables table inet netscope_<profile> │
│ ├── chain filter_dropship: ip daddr <blocked> drop │
│ └── chain route_exitlink: udp dport { ... } meta mark set 0x4e53 │
│ │ │
│ ▼ │
│ ip rule pref 1000 fwmark 0x4e53 lookup 20000 (Policy Routing) │
└────────────────────────────────────────────────────────────────────────┘
3. Attachment Strategies & Timing
Netscope provides three distinct attachment strategies depending on the application lifecycle:
1. Pre-Exec Attachment (Native ELF)
- Mechanism:
fanotifymarks the executable inode withFAN_OPEN_EXEC_PERM. - Timing: Kernel holds the
execvesyscall before the binary entry point runs. - Guarantee: The process is moved into
/sys/fs/cgroup/netscope/<profile>before its very first instruction or socket is created.
2. Pre-Open Attachment (Wine / Proton PEs)
- Mechanism:
fanotifymarks the.exeWindows PE inode withFAN_OPEN_PERM. - Timing: Kernel holds the Wine loader’s open of the PE file. The daemon inspects
/procto find the Wine process loading the binary. - Guarantee: The Wine process is attached before Windows game code initializes, ensuring 100% of game sockets are created inside the cgroup.
3. Late Attachment (Already-Running Processes)
- Mechanism: Polling scanner matches process
commor argv basename. - Timing: Attached post-launch.
- Guarantee: Process is moved into the cgroup for future sockets, and existing open sockets are duplicated via
pidfd_getfdand marked withSO_MARK, routing active flows without restarting the app.
4. Security & Privilege Boundaries
- Daemon Boundary:
netscopedrequiresCAP_NET_ADMINandCAP_SYS_ADMINto configure cgroups, nftables, and fanotify. It runs as a root systemd service (netscoped.service). - Socket Permissions: The control socket
/run/netscope/control.sockis created with mode0660owned byroot:netscope. Unprivileged users must be members of thenetscopegroup to interact with the daemon. - Peer Credential Validation:
netscopedchecksSO_PEERCREDon every socket connection. A non-root client can only manage profiles matching their own UID (target_uid == peer_uid). - Fallback Safety: If a client crashes,
Hold::Peerprevents rule leaks. If the daemon crashes,netscoped.serviceauto-restarts and reconciles the routing rules and cgroup state.
Shared scopes
A shared scope lets more than one program manage the same process without fighting over its cgroup. ExitLink and Dropship are the motivating pair: one routes the game’s traffic, the other filters it, and Linux gives a process exactly one cgroup. If each tool created its own cgroup, whichever attached second would silently break the other tool’s selector.
Identity
A scope is shared on a normalized process identity plus the target UID:
| Input | Key |
|---|---|
--process Overwatch.exe | name:overwatch, uid |
--wine /games/Overwatch/Overwatch.exe | name:overwatch, uid |
--exe /usr/bin/game | native:dev:ino, uid |
--steam 2357570 | steam:2357570, uid |
The last path component is lowercased and a trailing .exe is dropped, so
Overwatch, Overwatch.exe, overwatch.exe, and a Wine PE path all land on
one scope. A native target stays exact by device and inode; it never merges
with a name.
Two consumers with the same key share one profile, one cgroup, one mark, and one nftables table. The first consumer creates it; the last consumer to close removes it.
Claims
open registers a consumer:
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch --process Overwatch.exe --route tun0
sudo netscope --uid "$(id -u)" open --consumer dropship --process Overwatch.exe --filter allow \
--deny-cidr 64.224.26.0/23
Both commands return the same profile. An API client that keeps its control
connection open can use a peer hold; the claim is released when that connection
closes, which is the right lifetime for a running app. The CLI itself is
one-shot, so it always uses a persistent hold. A consumer that crashes without
closing a peer claim is cleaned up by the daemon; a persistent claim stays until
netscope close or netscope remove.
close releases one consumer:
sudo netscope close --consumer dropship overwatch
The profile remains while any claim remains. The last close removes the
cgroup, nftables table, routing table, and fanotify holds.
resolve finds the shared profile from a target or an id:
sudo netscope --uid "$(id -u)" resolve --process Overwatch.exe
sudo netscope resolve --profile overwatch
Policies
Each consumer owns its own policy. One profile can carry one route policy and any number of filter policies:
| Consumer | Policy | Effect |
|---|---|---|
| exitlink | route tun0 | mark packets for the tunnel table |
| dropship | filter allow, deny 64.224.26.0/23 | drop only the listed destinations; everything else falls through |
A second consumer cannot take the route: route ownership is exclusive. Filters compose before the route mark: a drop from any filter is terminal for that packet, so a Dropship drop never reaches ExitLink’s mark. In a drop filter, loopback is always accepted; an allow filter can deny it explicitly. Route policies can also exclude destinations:
sudo netscope policy overwatch --consumer exitlink --route tun0 \
--except 203.0.113.0/24
An exception gets an ip rule to main before the profile’s mark rule, so the
listed destination stays direct while everything else uses the tunnel.
Launching and attaching
For a name target, open also installs a pre-exec hold on the Wine and Proton
loader binaries it can find. When a loader is about to exec, the daemon checks
whether its launcher ancestry or the loader’s own command line names the
scope’s target; if it does, the loader is moved into the cgroup before it runs.
The game process inherits that cgroup, so its first socket is already scoped.
The daemon then learns the PE path from the loader’s command line and holds the
PE open as well. A Wine target stores the PE path and holds the PE directly.
Already-running processes still attach late on the poll interval, and their existing sockets are marked with the profile’s firewall mark. A name scope is therefore pre-exec for the next launch and late-with-marking for a process that was already running.
Protocol
The daemon’s control socket speaks one JSON request per line. A consumer can
use the netscope CLI or speak the protocol directly:
{"id":1,"op":"open","consumer":"exitlink","name":"overwatch","target":{"kind":"name","name":"Overwatch.exe"},"target_uid":1000,"hold":"peer"}
{"id":2,"op":"set_policy","profile":"overwatch","consumer":"exitlink","policy":{"kind":"route","interface":"tun0"}}
{"id":3,"op":"close","profile":"overwatch","consumer":"exitlink"}
open is idempotent for a (target, target_uid, consumer) tuple. set_policy
replaces that consumer’s policy atomically and rolls back on failure. The
daemon serializes every cgroup, nftables, and routing mutation behind one lock,
so two consumers cannot interleave a half-written ruleset.
Full schema, error semantics, every operation, and multi-language client examples are in Daemon Protocol & SDK.
Limits
- A name target attaches every process with that name under the target UID. There is no per-install-path identity for an already-running process.
- Do not create two profiles whose targets overlap the same process. A process has one cgroup and one firewall mark, so the profiles would fight over it. Share one scope instead.
- Only one route policy per shared profile; use separate profiles for two different tunnels.
- The loader hold covers Wine/Proton binaries that the daemon can discover and
whose launcher ancestry or command line names the target. A custom Wine build
outside the scanned directories, or a launcher whose command line does not
name the game, falls back to late attach. The PE path is learned only when
the launcher passes an absolute,
Z:\, orC:\path; a bare basename still gets the loader hold but no PE hold. filterandrouteare the only policy kinds. There is no TCP redirect, proxy, or per-destination policy in this layer.
Daemon Protocol & SDK
netscoped exposes its control interface on a Unix domain socket. The socket
speaks newline-delimited JSON (NDJSON): one UTF-8 JSON object per line, one
response per request. This is the same transport the netscope CLI uses, so
anything the CLI can do is available to your own program.
The default socket is /run/netscope/control.sock. It is created with mode
0660, owned by root:netscope; a client must be root or a member of the
netscope group. The path can be changed with [daemon] socket_path in
/etc/netscope/config.toml. The protocol version is reported as
api_version by status; this document describes version 2.
Two ways to use it:
- Rust: the
netscope-clientcrate wraps the protocol with typed methods and connection lifetime management. - Any language: connect to the socket and exchange NDJSON. Complete Python, Go, C, and shell clients are at the end of this page.
Transport
| Property | Value |
|---|---|
| Socket type | AF_UNIX, SOCK_STREAM |
| Default path | /run/netscope/control.sock |
| Framing | One JSON object per line, terminated by \n |
| Encoding | UTF-8 |
| Max request | 256 KiB (MAX_REQUEST_BYTES) |
| Max response | 256 KiB (MAX_RESPONSE_BYTES) |
| Request timeout | 10 seconds |
| Peer identity | Kernel SO_PEERCRED UID of the connecting process |
The daemon reads one line, handles it, writes one line, and repeats. Requests
on one connection are processed in order; a client may send several requests
over the same connection, waiting for each response before sending the next.
A trailing \r before the newline is tolerated.
A frame that is invalid ends the conversation: after replying with an error,
the daemon closes the connection. Reconnect for the next request. Anything over
256 KiB, or a line that is not valid JSON, is rejected with request id 0.
A successful payload that would exceed 256 KiB is replaced by an error response
for the same request id (response too large).
Wire format
Request envelope
{"id": 1, "op": "status"}
| Field | Type | Meaning |
|---|---|---|
id | u64 | Client-chosen request id, echoed in the response. Use a new value per request. |
op | string | Operation name in snake_case. |
| (args) | — | Operation-specific fields, flattened at the top level beside id and op. |
The operation tag is flattened into the envelope, so arguments sit beside
op, not inside a nested object.
Response envelope
{"id": 1, "ok": true, "data": {"api_version": 2}, "error": null}
| Field | Type | Meaning |
|---|---|---|
id | u64 | The request id being answered, or 0 for a framing error. |
ok | bool | true for success, false for an error. |
data | any | Operation result on success; null on error. |
error | string | Human-readable message on failure; null on success. |
Both data and error are always present in the serialized response and are
null when not applicable.
Errors
{"id": 3, "ok": false, "data": null, "error": "profile overwatch not found"}
Example error messages:
| Error | Cause |
|---|---|
invalid json: ... | The line was not valid JSON. Answered with id 0. |
invalid request: ... | Valid JSON, but not a valid request envelope or arguments. Echoes the parsed id when available. |
request too large | The line exceeded 256 KiB. Answered with id 0. |
request timeout | No complete line arrived within 10 seconds. Answered with id 0. |
permission denied: target_uid must match your uid | A non-root caller named another user’s UID. |
permission denied: profile owned by uid N | The caller is neither root nor the profile owner. |
profile <id> not found | Unknown profile id. |
consumer <id> does not hold profile <id> | Policy or close requested before the consumer called open. |
route policy already owned by <consumer> | A second consumer asked for the route on a shared profile. |
profile limit reached (N) | All profile slots are in use; N is max_profiles (32 by default). |
Scripts should treat any non-zero ok as a rejected request and keep the
connection policy simple: reconnect after an error response.
Versioning
status returns api_version. A client should read it once per connection
and refuse to continue if it is not the version it was written for. Version 2
covers the shared-scope operations described here.
Holds and claim lifetime
open registers a claim. hold chooses how that claim is owned:
hold | Owner | Released when |
|---|---|---|
"peer" | The client socket connection | The client exits, crashes, or closes the connection. The kernel closes the socket; the daemon sees end-of-file and frees the claim and, if it was the last claim, the cgroup, nftables table, and routes. |
"persistent" | The daemon’s state file | An explicit close or remove. It survives a client restart and a daemon restart. |
Use peer for a running application or a long-lived helper that can keep one
connection open. The SDK’s Client owns that connection: keep the Client
alive for as long as the scope should exist. Use persistent for one-shot
tools, scripts, and the netscope CLI itself. The CLI always uses a persistent
claim because it exits after every command.
A consumer that already holds a persistent claim keeps it if it later opens
again with hold: "peer"; a peer claim can be upgraded to persistent, never
downgraded.
If a client sends close for a profile with other remaining consumers, the
profile survives with those claims. The last close removes the profile
(removed: true). A peer connection that disappears is released the same way,
consumer by consumer.
Data types
TargetSpec
The target is a process identity, not a command line. Its normalized form is
shared across consumers: a Wine PE and a bare process name unify on the
basename (Overwatch.exe, overwatch.exe, and a Wine path all become
overwatch), a native executable stays exact by device and inode, and Steam
targets match by app id.
{"kind": "native", "path": "/usr/bin/curl"}
{"kind": "wine", "path": "/games/Game/Game.exe"}
{"kind": "name", "name": "Overwatch.exe"}
{"kind": "steam", "app_id": 2357570}
kind | Fields | Rules |
|---|---|---|
native | path | Absolute path to an ELF executable. Canonicalized by the daemon. |
wine | path | Absolute path to a Windows PE that starts with an MZ header. |
name | name | Process comm or argv basename. No path separators; at most 64 printable characters. Matched case-insensitively with a trailing .exe optionally stripped. |
steam | app_id | Non-zero numeric Steam app id. |
PolicySpec
A policy is either a filter or a route. A shared profile may carry any number of filter policies, but only one route policy.
Filter
{"kind": "filter", "default": "drop", "allow_cidrs": ["203.0.113.0/24"]}
{"kind": "filter", "default": "allow", "deny_cidrs": ["64.224.26.0/23"]}
| Field | Type | Meaning |
|---|---|---|
default | "drop" or "allow" | Action for destinations not listed. |
allow_cidrs | array of strings | Kept reachable with default: "drop". |
deny_cidrs | array of strings | Dropped with default: "allow". |
default: "drop" accepts allow_cidrs and rejects deny_cidrs;
default: "allow" is the reverse and requires at least one deny_cidrs
entry. A drop filter always accepts loopback unless an allow filter
explicitly denies it. Filters run before the route mark, so a drop from any
consumer is terminal for that packet.
Route
{
"kind": "route",
"interface": "tun0",
"gateway": null,
"gateway6": null,
"links": [
{"interface": "enp7s0f4u1", "gateway": null, "gateway6": null},
{"interface": "wwan0", "gateway": "10.64.0.1", "gateway6": null}
],
"include_lan": false,
"allow_ipv6": false,
"udp_only": false,
"except_ports": [],
"except_sports": [],
"except": []
}
| Field | Type | Meaning |
|---|---|---|
interface | string | Tunnel interface to route through. |
gateway | string or null | Optional IPv4 next hop. |
gateway6 | string or null | Optional IPv6 next hop; requires allow_ipv6. |
links | array of RouteLink | Ordered failover links. Empty or omitted means the interface/gateway/gateway6 mirror is the only link. Each entry has interface, gateway (string or null), and gateway6 (string or null). At most 8, unique interfaces. |
include_lan | bool | Route LAN destinations instead of bypassing them. Defaults to false. |
allow_ipv6 | bool | Carry IPv6; without it, IPv6 is dropped. Defaults to false. |
udp_only | bool | Mark only IPv4 UDP; TCP keeps the host’s normal path. Defaults to false. |
except_ports | array of u16 | UDP destination ports that stay direct; requires udp_only. Up to 32. |
except_sports | array of u16 | UDP source ports that stay direct, pinning sockets that already existed when the route opened; requires udp_only. Up to 64. |
except | array of strings | Destination CIDRs that stay direct, installed as ip rules ahead of the profile’s mark. Up to 30. |
Omitted optional fields default to their zero values. gateway and
gateway6 may be omitted or sent as null.
Hold
"peer" or "persistent". See Holds and claim lifetime.
Operations
Every example below is a complete line for the socket. Responses are shown pretty-printed for readability; on the wire they are one line.
status
Daemon identity and the full profile list.
{"id": 1, "op": "status"}
{
"id": 1,
"ok": true,
"data": {
"api_version": 2,
"daemon_version": "0.2.1",
"pid": 1234,
"kernel": "6.11.0-arch1-1",
"cgroup_root": "/sys/fs/cgroup/netscope",
"state_path": "/var/lib/netscope/state.json",
"config_path": "/etc/netscope/config.toml",
"profiles": [
{
"id": "overwatch",
"mode": "route",
"enabled": true,
"target": "name:Overwatch.exe",
"cgroup": "/sys/fs/cgroup/netscope/overwatch",
"cgroup_level": 4,
"pids": [6120],
"nft_table": "inet netscope_overwatch",
"mark": "0x4e53",
"table": 20000,
"rule_priority": 1000,
"attached": "late",
"healthy": true,
"warnings": ["name target attaches late; existing sockets are marked"],
"consumers": ["exitlink"],
"policies": ["exitlink:route"],
"generation": 3,
"links": [],
"stats": {
"tx_bytes": 48213,
"rx_bytes": 912460,
"tx_packets": 412,
"rx_packets": 808,
"tx_rate_bps": 33824,
"rx_rate_bps": 2621440,
"dropped_bytes": 0,
"dropped_packets": 0,
"since_unix": 1790000000
}
}
]
},
"error": null
}
ProfileStatus fields:
| Field | Meaning |
|---|---|
id | Profile id. |
mode | "route" when a consumer owns the route, "filter" when only filters exist, "shared" when no policy is attached. |
enabled | Whether enforcement is active. |
target | Rendered target: name:<name>, wine:<path>, a native path, or steam:<app_id>. |
cgroup | Absolute cgroup path for the profile. |
cgroup_level | cgroup v2 depth used by the nftables socket cgroupv2 selector. |
pids | Attached process ids, sorted and deduplicated. |
nft_table | inet <table> when the profile has policies; empty string otherwise. |
mark | Firewall mark as a hexadecimal string, for example 0x4e53. |
table | Routing table id. |
rule_priority | ip rule priority for the profile’s mark. |
attached | pre-exec, pre-open, late, mixed, or none. |
healthy | Daemon health check result for the profile. |
warnings | Human-readable advisories, possibly empty. |
consumers | Consumer ids holding a claim. |
policies | Policy summaries, one per consumer: <consumer>:filter or <consumer>:route. |
generation | Incremented when a claim or policy changes; useful for change detection. |
links | Route links with probe state: interface, gateway, gateway6, phase (up/down), healthy (true when up), active (carrying the default route), rtt_ms, and last_ok_unix. Empty for filter profiles and for daemons older than 0.3.0. |
stats | Traffic counters and live rates, or null when the profile has no table. See ProfileStats. |
ProfileStats
stats is null when the profile has no nftables table (disabled, removed,
or without a policy) and for daemons older than 0.3.0.
| Field | Meaning |
|---|---|
tx_bytes / tx_packets | Outbound traffic and packets counted by both selectors (seen_cg + seen_mk), totalled since since_unix. |
rx_bytes / rx_packets | Inbound traffic and packets (rx_cg + rx_mk). The input hook sees all traffic in this network namespace, including local loopback, when a selector matches it. |
tx_rate_bps / rx_rate_bps | Smoothed bits per second (EWMA, α = 0.3) over the last one-second sample. |
dropped_bytes / dropped_packets | Traffic matched by a drop rule. |
since_unix | Daemon start in Unix seconds; totals cover this daemon lifetime, not the host uptime. |
The daemon samples nftables counters once per second and caches the result, so
status and list never run nft themselves. Totals are monotonic: when a
kernel counter goes backwards because the table was recreated (a policy or hook
change, or a profile disabled and re-enabled), the new raw value is used as the
delta and the totals keep growing. Rates decay toward zero while a profile is
idle. Everything resets when the daemon restarts; counters are not persisted.
open
Create a shared scope or join an existing one. Idempotent per
(target, target_uid, consumer): an existing consumer gets the same profile
back with a bumped generation.
{"id": 2, "op": "open", "consumer": "exitlink", "target": {"kind": "name", "name": "Overwatch.exe"}, "target_uid": 1000, "name": "overwatch", "hold": "peer"}
| Field | Type | Meaning |
|---|---|---|
consumer | string | Lowercase id (^[a-z][a-z0-9-]{0,31}$). |
target | TargetSpec | Process identity to scope. |
target_uid | u32 | Owner of the target process. A non-root caller must pass its own UID. |
name | string or omitted | Profile id to use when creating a new scope. Ignored when joining. |
hold | Hold | "peer" or "persistent". Required. |
Profile ids match ^[a-z0-9][a-z0-9-]{0,31}$. Without name, the daemon
derives one from the target and UID, for example overwatch-u1000.
{
"id": 2,
"ok": true,
"data": {
"profile": "overwatch",
"generation": 0,
"target": "name:Overwatch.exe",
"target_uid": 1000,
"state": "active",
"attached": "late",
"pids": [6120],
"consumers": ["exitlink"]
},
"error": null
}
ScopeHandle fields: profile, generation, target, target_uid,
state (active or disabled), attached, pids, consumers.
A named target that already has a scope merges the new claim into that existing
profile, so the profile in the response is the authoritative id and may
differ from the name you asked for: address every later set_policy and
close to the returned id. Closing by the requested name leaves the claim and
its policy behind in the shared scope.
Opening a name target also installs the Wine/Proton loader holds, so the next launch is moved into the cgroup before its first socket. A running process is attached on the poll and its existing sockets are marked.
set_policy
Replace one consumer’s policy atomically. The consumer must already hold a claim on the profile. On failure the previous policy is restored.
{"id": 3, "op": "set_policy", "profile": "overwatch", "consumer": "exitlink", "policy": {"kind": "route", "interface": "tun0", "udp_only": true, "except_ports": [53]}}
{
"id": 3,
"ok": true,
"data": {
"id": "overwatch",
"mode": "route",
"enabled": true,
"target": "name:Overwatch.exe",
"cgroup": "/sys/fs/cgroup/netscope/overwatch",
"cgroup_level": 4,
"pids": [6120],
"nft_table": "inet netscope_overwatch",
"mark": "0x4e53",
"table": 20000,
"rule_priority": 1000,
"attached": "late",
"healthy": true,
"warnings": ["name target attaches late; existing sockets are marked"],
"consumers": ["exitlink"],
"policies": ["exitlink:route"],
"generation": 1
},
"error": null
}
data is a ProfileStatus, the same object used in status and list.
Only one consumer can own the route; a second route request fails with
route policy already owned by <consumer>.
clear_policy
Remove one consumer’s policy without releasing its claim. The consumer keeps the scope open and can attach a different policy later.
{"id": 4, "op": "clear_policy", "profile": "overwatch", "consumer": "dropship"}
{
"id": 4,
"ok": true,
"data": {
"id": "overwatch",
"mode": "route",
"enabled": true,
"target": "name:Overwatch.exe",
"cgroup": "/sys/fs/cgroup/netscope/overwatch",
"cgroup_level": 4,
"pids": [6120],
"nft_table": "inet netscope_overwatch",
"mark": "0x4e53",
"table": 20000,
"rule_priority": 1000,
"attached": "late",
"healthy": true,
"warnings": ["name target attaches late; existing sockets are marked"],
"consumers": ["dropship", "exitlink"],
"policies": ["exitlink:route"],
"generation": 3
},
"error": null
}
close
Release one consumer’s claim. The profile is removed only when it was the last claim; the cgroup, nftables table, routing table, and fanotify holds are torn down at that point. This works for both peer and persistent claims.
{"id": 5, "op": "close", "profile": "overwatch", "consumer": "exitlink"}
{
"id": 5,
"ok": true,
"data": {
"profile": "overwatch",
"released": true,
"removed": false,
"consumers": ["dropship"]
},
"error": null
}
CloseResult fields: profile, released (the named consumer’s claim was
found and released), removed (the profile was torn down), consumers
(remaining consumer ids; empty when removed).
resolve
Look up a profile by id, or by the shared identity plus UID. Provide a
profile id, or a target with target_uid.
Either request form:
{"id": 6, "op": "resolve", "profile": "overwatch"}
{"id": 7, "op": "resolve", "target": {"kind": "name", "name": "Overwatch.exe"}, "target_uid": 1000}
The second form’s response:
{
"id": 7,
"ok": true,
"data": {
"id": "overwatch",
"mode": "route",
"enabled": true,
"target": "name:Overwatch.exe",
"cgroup": "/sys/fs/cgroup/netscope/overwatch",
"cgroup_level": 4,
"pids": [6120],
"nft_table": "inet netscope_overwatch",
"mark": "0x4e53",
"table": 20000,
"rule_priority": 1000,
"attached": "late",
"healthy": true,
"warnings": ["name target attaches late; existing sockets are marked"],
"consumers": ["exitlink"],
"policies": ["exitlink:route"],
"generation": 3
},
"error": null
}
data is a ProfileStatus. A miss is a normal error response
(profile <id> not found or no profile for target), not an empty object.
The SDK surfaces that as ClientError::Daemon; its Ok(None) corresponds
only to a null payload.
list
All profiles, without the daemon header that status carries.
{"id": 8, "op": "list"}
{
"id": 8,
"ok": true,
"data": [
{
"id": "overwatch",
"mode": "route",
"enabled": true,
"target": "name:Overwatch.exe",
"cgroup": "/sys/fs/cgroup/netscope/overwatch",
"cgroup_level": 4,
"pids": [6120],
"nft_table": "inet netscope_overwatch",
"mark": "0x4e53",
"table": 20000,
"rule_priority": 1000,
"attached": "late",
"healthy": true,
"warnings": ["name target attaches late; existing sockets are marked"],
"consumers": ["exitlink"],
"policies": ["exitlink:route"],
"generation": 3
}
],
"error": null
}
Disabled profiles are included. An empty daemon returns "data": [].
refresh
Force an immediate target re-stat and attach pass instead of waiting for the next poll interval. For a Wine target whose file changed, the fanotify mark is moved to the new inode.
{"id": 9, "op": "refresh"}
{"id": 9, "ok": true, "data": {"updated": 1}, "error": null}
updated counts profile targets and held executables whose device/inode
identity changed during the re-stat. Attaching runs regardless of that count.
refresh does not repair a deleted nftables table; the daemon’s health loop
does that.
Administrative operations
The same transport carries the CLI’s single-owner operations. Their responses
use ProfileStatus for add, enable, and disable, and a small object for
remove:
| Request | Success data |
|---|---|
{"id": 10, "op": "add", "name": "blocked", "target": {"kind": "native", "path": "/usr/bin/curl"}, "mode": {"kind": "filter", "default": "drop"}, "target_uid": 1000} | ProfileStatus |
{"id": 11, "op": "remove", "name": "blocked"} | {"removed": "blocked"} |
{"id": 12, "op": "enable", "name": "blocked"} | ProfileStatus |
{"id": 13, "op": "disable", "name": "blocked"} | ProfileStatus |
add uses mode (a PolicySpec) rather than policy, matching the CLI’s
one-shot profile model. See the CLI reference for the rules
around each target and policy flag; the daemon enforces the same validation.
Rust SDK: netscope-client
The crate lives at crates/netscope-client in this repository. It wraps the
socket, assigns request ids, checks response ids, and enforces the 10-second
timeouts.
[dependencies]
netscope-client = { path = "../netscope/crates/netscope-client" }
use netscope_client::{Client, ClientError, Hold, PolicySpec};
fn main() -> Result<(), ClientError> {
// One Client owns one socket connection. Keep it alive for a peer hold.
let mut client = Client::connect()?;
let handle = client.open_process(
"exitlink",
"Overwatch.exe",
1000,
Hold::Peer,
)?;
println!("profile {} ({})", handle.profile, handle.state);
client.set_policy(
&handle.profile,
"exitlink",
PolicySpec::Route {
interface: "tun0".to_string(),
gateway: None,
gateway6: None,
include_lan: false,
allow_ipv6: false,
udp_only: true,
except_ports: vec![53],
except_sports: Vec::new(),
except: Vec::new(),
},
)?;
client.close_scope(&handle.profile, "exitlink")?;
Ok(())
}
Public API:
| Method | Purpose |
|---|---|
Client::connect() | Connect to /run/netscope/control.sock. |
Client::connect_at(path) | Connect to a non-default socket. |
Client::is_available() / is_available_at(path) | Probe without keeping a client. |
status() | StatusData for the daemon and every profile. |
open_scope(OpenScope) | Escape hatch for all open fields. |
open_process(consumer, process_name, target_uid, hold) | Create or join a name scope. |
set_policy(profile, consumer, PolicySpec) | Replace a consumer’s policy. |
clear_policy(profile, consumer) | Remove a consumer’s policy, keep its claim. |
close_scope(profile, consumer) | Release a claim; removes the profile when last. |
resolve(profile, target, target_uid) | Find a profile as Option<ProfileStatus>; a miss is ClientError::Daemon. |
list() | Every ProfileStatus. |
refresh() | Force a re-stat and attach pass. |
raw_request(RequestOp) | Send any operation and get the raw data. |
ClientError distinguishes Unavailable (missing socket, nothing listening,
or the daemon closed without replying), Permission (the socket rejected the
connection), Daemon (the daemon replied ok: false, message preserved),
Protocol (bad JSON, oversized response, or mismatched id), and Io.
Re-exports include Hold, TargetSpec, PolicySpec, FilterDefault,
RouteLink, LinkPhase, LinkStatus, OpenScope, ScopeHandle,
CloseResult, StatusData, ProfileStatus, and RequestOp.
Python client
A complete standalone client. It opens one connection per request, which is fine for inspection and one-shot calls; keep one socket open if you use a peer hold.
import json
import socket
SOCKET = "/run/netscope/control.sock"
def call(op, **args):
request = {"id": 1, "op": op, **args}
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(SOCKET)
sock.sendall(json.dumps(request).encode("utf-8") + b"\n")
reply = sock.makefile("rb").readline()
response = json.loads(reply)
if not response["ok"]:
raise RuntimeError(response["error"])
return response["data"]
status = call("status")
print(status["daemon_version"], status["api_version"])
handle = call(
"open",
consumer="exitlink",
target={"kind": "name", "name": "Overwatch.exe"},
target_uid=1000,
name="overwatch",
hold="persistent",
)
print(handle["profile"], handle["state"])
print(call("close", profile=handle["profile"], consumer="exitlink"))
Go client
A complete program using net.Dial("unix", …). json.Encoder.Encode appends
the newline the protocol requires.
package main
import (
"encoding/json"
"fmt"
"net"
)
func main() {
conn, err := net.Dial("unix", "/run/netscope/control.sock")
if err != nil {
panic(err)
}
defer conn.Close()
req := map[string]any{"id": 1, "op": "status"}
if err := json.NewEncoder(conn).Encode(req); err != nil {
panic(err)
}
var resp struct {
ID uint64 `json:"id"`
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *string `json:"error"`
}
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
panic(err)
}
if !resp.OK {
panic(*resp.Error)
}
fmt.Println(string(resp.Data))
}
C client
POSIX sockets only, no external library. Compile with cc client.c -o client.
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
static int call(const char *request, char *reply, size_t reply_len)
{
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0)
return -1;
struct sockaddr_un addr = { .sun_family = AF_UNIX };
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s",
"/run/netscope/control.sock");
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
close(fd);
return -1;
}
if (send(fd, request, strlen(request), 0) < 0) {
close(fd);
return -1;
}
size_t used = 0;
while (used + 1 < reply_len) {
ssize_t n = recv(fd, reply + used, reply_len - used - 1, 0);
if (n <= 0)
break;
used += (size_t)n;
if (reply[used - 1] == '\n')
break;
}
reply[used] = '\0';
close(fd);
return (int)used;
}
int main(void)
{
char reply[4096];
const char *request = "{\"id\":1,\"op\":\"status\"}\n";
if (call(request, reply, sizeof(reply)) <= 0) {
fprintf(stderr, "netscoped not reachable\n");
return 1;
}
fputs(reply, stdout);
return 0;
}
Shell
The CLI is the shortest path for one-shot calls:
netscope status --json
netscope list --json
netscope refresh
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch \
--process Overwatch.exe --route tun0
sudo netscope policy overwatch --consumer exitlink --route tun0 --udp --except-port 53
sudo netscope close overwatch --consumer exitlink
Raw NDJSON with socat:
printf '%s\n' '{"id":1,"op":"status"}' | socat - UNIX-CONNECT:/run/netscope/control.sock
Open a scope, read its response, then close it:
printf '%s\n' '{"id":1,"op":"open","consumer":"exitlink","target":{"kind":"name","name":"Overwatch.exe"},"target_uid":1000,"name":"overwatch","hold":"persistent"}' \
| socat - UNIX-CONNECT:/run/netscope/control.sock
printf '%s\n' '{"id":2,"op":"close","profile":"overwatch","consumer":"exitlink"}' \
| socat - UNIX-CONNECT:/run/netscope/control.sock
Each socat invocation is a fresh connection. A hold: "peer" claim from one
of these one-liners is released as soon as socat exits, so use persistent
or keep a single connection open for the scope’s life.
Permissions
The socket is root-owned and group-accessible. The daemon derives the
caller’s UID from the kernel (SO_PEERCRED), not from the request:
- A non-root caller must pass its own UID as
target_uid; naming another user’s UID is rejected. - Mutating an existing profile requires root or the profile’s owner.
- A consumer can only set or clear a policy after it has called
open. - Root may act on any profile.
For the deployment model, see Security. For the CLI rules that mirror these checks, see the CLI reference.
Filter mode
Filter mode accepts or drops packets from one application. On its own it does not change routing: accepted traffic follows the machine’s normal routes. A shared profile that also carries a route policy sends accepted traffic through that route.
sudo netscope --uid "$(id -u)" add blocked --exe /usr/bin/curl --filter drop
Defaults
--filter takes one value:
| Value | Behavior |
|---|---|
drop | Loopback and --allow-cidr destinations are accepted; everything else is dropped. |
allow | --deny-cidr destinations are dropped; everything else is accepted. |
Loopback is always accepted in drop mode, for both IPv4 (127.0.0.0/8) and IPv6 (::1/128). allow mode accepts loopback unless you deny it explicitly with a CIDR.
Exceptions
--filter drop --allow-cidr
Each --allow-cidr is a destination network that remains reachable. Repeat the flag for multiple networks:
sudo netscope --uid "$(id -u)" add blocked-allow \
--exe /usr/bin/curl \
--filter drop \
--allow-cidr 203.0.113.10/32 \
--allow-cidr 2001:db8::/32
--allow-cidr is only valid with --filter drop. It is invalid with --filter allow.
--filter allow --deny-cidr
allow mode requires at least one --deny-cidr; without one the profile would accept everything and do nothing.
sudo netscope --uid "$(id -u)" add limited \
--exe /usr/bin/curl \
--filter allow \
--deny-cidr 198.51.100.0/24
--deny-cidr is only valid with --filter allow. It is invalid with --filter drop.
CIDRs must parse as IPv4 or IPv6 networks. An invalid CIDR is rejected with exit code 2 before the profile is created.
Evaluation order
--filter drop emits a negated drop per family: everything except loopback and
the --allow-cidr destinations is dropped, and non-IP traffic is dropped too.
An accepted packet falls through to any route policy in the same shared
profile, so a filter never silently replaces a route. --filter allow drops
only the --deny-cidr destinations and lets everything else fall through.
What the rules match
The generated rules match sockets in the profile’s cgroup and sockets carrying the profile’s firewall mark:
socket cgroupv2 level 2 "netscope/<profile>"
socket mark 0x4e53
The second form covers a socket that existed before a late attach: the daemon cannot move that socket into the cgroup, so it marks the socket itself. Both rules are repeated for every filter rule. They do not match by executable name, user, or port. All protocols and ports are covered, including TCP, UDP, QUIC, and ICMP, as long as the packet’s socket is in the cgroup or carries the mark:
- TCP and UDP: the socket belongs to the process, so the rule applies.
- QUIC: carried over UDP and treated the same way.
- ICMP echo: uses an ICMP socket owned by the sending process, so it is matched.
DNS
Filter mode does not special-case DNS. In drop mode, name resolution fails unless the resolver’s address is allowed:
sudo netscope --uid "$(id -u)" add blocked \
--exe /usr/bin/curl \
--filter drop \
--allow-cidr 1.1.1.1/32 \
--allow-cidr 1.0.0.1/32
If the system resolver is a local stub such as 127.0.0.53, the query to the stub is loopback and is accepted, but the stub’s own upstream query is a different process and is not filtered by this profile. Allow the real upstream resolver if the stub forwards over the network.
Host names cannot be used as exceptions. Resolve them first and allow the address or network.
Existing connections
The filter applies to packets, not to an application’s lifetime. A socket that was open before a late attach is marked with the profile’s firewall mark, so its packets are matched without a restart. If a descriptor cannot be marked, the daemon logs mark profile=... error=... and that socket continues on its old path; restart the application to cover it.
Changes and removal
- A consumer’s policy can be replaced with
netscope policy;netscope addstill creates a one-shot profile owned bynetscope-cli. enableanddisablekeep the profile and its cgroup but stop or resume enforcement.removedeletes the nftables table and the cgroup. Traffic is unfiltered immediately.
Interaction with route mode
A shared profile can carry several filter policies and one route policy, one
policy per consumer. Filters run first; a packet dropped by any consumer is
terminal, and an accepted packet continues to the route mark. A second
filter or route consumer for the same target and target uid joins that profile
through netscope open; a second netscope add is refused. See
Shared scopes.
Examples
Drop everything except one API and its DNS resolver:
sudo netscope --uid "$(id -u)" add api-only \
--exe /usr/bin/myapp \
--filter drop \
--allow-cidr 203.0.113.10/32 \
--allow-cidr 1.1.1.1/32
Allow everything except a LAN subnet:
sudo netscope --uid "$(id -u)" add no-lan \
--exe /usr/bin/myapp \
--filter allow \
--deny-cidr 192.168.0.0/16
Verify with netscope status, then remove with netscope remove api-only.
Route mode
Route mode sends one application’s traffic through an existing tunnel interface. It uses a firewall mark and a dedicated routing table; it does not create the tunnel, configure the interface, or manage WireGuard, OpenVPN, or any other VPN.
sudo netscope --uid "$(id -u)" add routed --exe /usr/bin/curl --route tun0
The interface must already exist and be up. If it does not, the daemon fails to apply the profile and the CLI exits 1.
Options
| Option | Meaning |
|---|---|
--route <iface> | Interface for the default route, for example tun0 or wg0. Required unless --link is given. |
--link <spec> | Ordered failover link, repeatable: <iface>[@<ipv4>][#<ipv6>]. Conflicts with --route and the standalone gateway flags. |
--gateway <ipv4> | IPv4 next hop. Optional when the interface is point-to-point. |
--gateway6 <ipv6> | IPv6 next hop. Only valid with --allow-ipv6. |
--include-lan | Route LAN destinations through the tunnel instead of bypassing them. |
--allow-ipv6 | Carry IPv6 through the profile. Without it, all IPv6 from the profile is dropped. |
--udp | Mark only IPv4 UDP. TCP keeps its normal route, so a TCP proxy can own TCP on the same process. |
--except-port <port> | UDP destination port that stays direct. Repeatable, up to 32; requires --udp. |
--except-sport <port> | UDP source port pinned direct. Repeatable, up to 64; requires --udp. |
--except <cidr> | Destination that stays direct. Repeatable, up to 30. The daemon installs an ip rule to main at a lower priority than the mark rule. Loopback is always kept direct the same way and does not count against the 30. |
--gateway and --gateway6 are mutually exclusive with the wrong family: --gateway6 requires --allow-ipv6, and --gateway is IPv4 only.
What gets routed
The nftables chain for a route profile:
- accepts loopback (
127.0.0.0/8,::1/128); - unless
--include-lan, accepts the LAN and link-local ranges listed below; - unless
--allow-ipv6, drops all IPv6; - with
--udp, accepts the--except-portdestinations and the--except-sportsources, then sets the mark on IPv4 UDP only; without it, sets the mark on everything left.
--except-sport exists for a route that opens over a live flow. A UDP socket keeps its source port for its whole life, and the mark applies to every packet it sends, so marking one mid-session moves the flow to the tunnel and changes the source address the peer sees. Pinning the source port leaves that socket on its normal route; sockets created afterwards are marked and routed as usual. The daemon never marks a pinned socket, so the pin survives later attaches.
The mark is then matched by an ip rule:
fwmark 0x4e53 table 20000 pref 1000
The table contains two defaults:
| Route | Metric | Effect |
|---|---|---|
default dev <iface> [via <gateway>] | 100 | Used while the interface has a usable route. |
blackhole default | 1000 | Used when the real route is gone, so traffic fails instead of falling back to the main table. |
With ordered links the tunnel default is moved between links, or removed entirely while every link is down; the blackhole stays installed either way.
The mark and table values come from the profile index. Table 20000 and mark 0x4e53 are the first profile; the next profile uses 20001 and 0x4e54, up to 32 profiles.
Each --except adds a rule just above the mark rule:
fwmark 0x4e53 to 203.0.113.0/24 table main pref 999
A packet from the profile to an excepted destination matches that rule first and uses the main table; everything else reaches the mark rule and uses the profile table. Exceptions are removed with the profile.
UDP-only routes
--udp narrows the mark rule to IPv4 UDP. TCP from the same process is not
marked and keeps the host’s normal route, which is what lets a TCP proxy and a
UDP route share one application. Excluded UDP ports return with accept
before the mark, so DNS (53) or a game’s own local port can stay direct:
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch \
--process Overwatch.exe --route tun0 --udp \
--except-port 53 --except-port 29523
Late attach still covers sockets that existed before the profile: the daemon sets the same mark on the process’s open descriptors, and only UDP packets are matched by the narrowed rule.
LAN bypass
By default — include_lan = false — route mode bypasses the tunnel for local networks. This keeps printers, game consoles, local services, and router administration reachable while the app’s internet traffic is routed.
The bypass list is fixed:
| Family | Bypassed destinations |
|---|---|
| IPv4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 224.0.0.0/4 |
| IPv6 | fc00::/7, fe80::/10, ff00::/8 |
--include-lan (include_lan = true) removes this bypass, so traffic to those ranges is marked and routed through the tunnel. Use it when the tunnel carries the LAN (for example a remote-access VPN that reaches a home network) and the application must use it.
Ranges outside this list, such as CGNAT 100.64.0.0/10, are not bypassed. They follow the profile’s default route.
IPv6
The default is conservative: allow_ipv6: false drops every IPv6 packet from the profile, including link-local traffic that would otherwise bypass the tunnel. This avoids a dual-stack leak when the tunnel carries only IPv4.
To carry IPv6:
sudo netscope --uid "$(id -u)" add routed6 \
--exe /usr/bin/curl \
--route tun0 \
--allow-ipv6 \
--gateway6 2001:db8::1
With --allow-ipv6, the daemon also installs an IPv6 policy rule and an IPv6 default route in the same table. If the IPv6 route cannot be installed, the add fails and the profile is not left applied. If the interface later loses that route, the IPv6 blackhole matches and the traffic fails instead of using the main table. --gateway6 is optional for point-to-point interfaces.
Ordered links and failover
--link replaces --route with an ordered list of interfaces, and a gateway
belongs inside the link spec: --link wlan0@192.168.1.1 is the canonical form.
The legacy --route wlan0 --gateway 192.168.1.1 (with --gateway6 for the
IPv6 half) is still accepted and maps to a single-link profile with the same
fields.
The profile gets one default route at a time, owned by the first link that is
healthy. That single winner is used for both families. A link is probed over
IPv4 when it has an IPv4 source address, and otherwise over IPv6 when the
profile allows IPv6, so a link can be the healthy winner while its other
family has no path. With --allow-ipv6, a winner with no usable IPv6 path
has no IPv6 tunnel default, the IPv6 blackhole matches, and IPv6 from the
profile fails instead of moving to a lower-priority link that could carry it.
A link switch deletes the previous winner’s IPv6 default as well, so the
stale route cannot leak IPv6 past a v6-incapable winner.
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch \
--process Overwatch.exe \
--link enp7s0f4u1 --link wwan0@10.64.0.1
Each link is probed every probe_interval_ms (2000 by default). A probe
binds to that interface, clears any inherited firewall mark, and opens a TCP
connection to probe_target with a one-second timeout; an interface with no
global source address is probed with ICMP through ping -I instead. When the
link spec carries a gateway, the probe first installs a host route for the
probe target (/32 for IPv4, /128 for IPv6) through that gateway on the
link’s interface. Without it, SO_BINDTODEVICE makes the off-link target
resolve as on-link, ARP fails, and a healthy link reads as down. Probe failure
is a health signal, not a daemon error.
| State | Rule |
|---|---|
| Failure | Three consecutive failed probes take a link from up to down. |
| Recovery | Two consecutive successful probes take it from down back to up. |
| Winner | The first link in --link order whose phase is up. |
| Failback | A recovered higher-priority link takes over only after its second success, so a flapping link does not bounce the route. |
| All down | No tunnel default is installed; the profile’s blackhole rejects its traffic. |
The daemon applies the winner’s default route immediately on a change. Its
health reconciler (exists_for/ensure_for) follows the current winner as
well, so a switched default is not reset to the first configured link on the
next tick, and a profile whose links are all down keeps its rules and blackhole.
Status shows the probe state for every link:
NAME MODE ENABLED TARGET ATTACHED CONSUMERS POLICIES PIDS
overwatch route yes name:Overwatch.exe late exitlink exitlink:route 6120
link enp7s0f4u1 10.0.0.1 up 41ms -
link wwan0 10.64.0.1 up 88ms active
status --json carries the same data in the profile’s links array. The
winner is the entry with active: true; healthy mirrors the phase.
This is not a kill switch for the whole machine. It protects traffic that enters the profile — the selected executable, process tree, or Wine launch. A game started outside the scoped path has no mark, no route, and no blackhole, so it is unaffected by failover.
Fail-closed while active, fail-open on removal
The blackhole protects traffic while the profile is active and the tunnel goes down: the real default route disappears, the blackhole at metric 1000 matches, and the packet is rejected.
It is not a system-wide kill switch, and it is not designed to survive the daemon being removed:
netscope disablestops enforcement for that profile.netscope removedeletes the nftables table, the policy rule, and the routes. Marked traffic then falls through to the main table.- A clean
systemctl stop netscopedremoves the daemon’s host state, and manual removal of the table has the same result: the guarantee ends and normal routing resumes. - A reboot clears the rules.
- If the daemon is killed without cleanup, the installed rules can remain. A restart reconciles and re-applies them for enabled profiles; to remove them, stop the daemon and run
sudo netscope cleanup --all. Until they are removed, the blackhole still applies.
In short: daemon death and table removal are fail-open once the rules are gone. The blackhole is a property of the installed rules, not of the daemon’s liveness.
Treat route mode as leak protection for the selected application while netscope is running, not as a firewall that prevents all leaks on the machine. If you need a hard kill switch, enforce it in the tunnel configuration or with a separate firewall policy.
DNS
netscope does not configure DNS. The application’s DNS queries are routed like its other UDP traffic through the tunnel. If the tunnel does not carry DNS or does not push a resolver, name resolution fails even though the interface is up. Configure DNS in the tunnel client or use a resolver reachable through the tunnel.
Queries sent to a local stub resolver are delivered over loopback and are therefore accepted before the mark. The stub’s own upstream queries belong to the stub process and are routed by that process, not by this profile.
Existing connections
A socket that existed before the process was attached is marked with the
profile’s firewall mark. Changing SO_MARK resets the kernel’s cached
destination, so packets sent after the attach use the profile’s routing table.
The connection is not re-established: TCP state, sequence numbers, and the
peer stay as they were, and only the path changes. If a descriptor could not be
marked, the daemon logs mark profile=... error=...; restart the application
to cover that socket.
Interaction with filter mode
A shared profile can carry one route policy and several filter policies. Each filter runs before the route mark. A destination dropped by any filter is never routed; an accepted destination is marked and follows this route. A second consumer asking for a route on a profile that already has one gets an error; route ownership moves only when the owning consumer replaces its own policy. See Shared scopes.
Inspect the routing state
ip rule show
ip route show table 20000
nft list table inet netscope_routed
Mark and table are allocated by profile index: the first profile uses mark 0x4e53 and table 20000, and each additional profile takes the next free index. netscope status --json reports each profile’s mark, table, and nft_table; ip rule show maps a mark to its table.
Removing a route profile
sudo netscope remove routed
The daemon deletes the IPv4 (and, when enabled, IPv6) policy rule, flushes the table, and removes the nftables table. The application keeps running and returns to the main routing table. Restart it if you need its existing connections to follow the new path.
Configuration
The daemon reads one TOML file, /etc/netscope/config.toml. It is root-owned and intended to be edited only by an administrator.
The shipped default is:
[daemon]
socket_path = "/run/netscope/control.sock"
state_path = "/var/lib/netscope/state.json"
cgroup_root = "/sys/fs/cgroup/netscope"
poll_interval_ms = 1000
refresh_interval_secs = 5
max_profiles = 32
log_level = "info"
probe_interval_ms = 2000
probe_target = "1.1.1.1:443"
probe_target6 = "[2606:4700:4700::1111]:443"
Keys
| Key | Default | Meaning |
|---|---|---|
socket_path | /run/netscope/control.sock | Unix socket for CLI requests. Root-owned, group netscope, mode 0660. |
state_path | /var/lib/netscope/state.json | Persistent profile state. Do not edit it by hand. |
cgroup_root | /sys/fs/cgroup/netscope | Parent cgroup for profiles. It must be an absolute path inside /sys/fs/cgroup. |
poll_interval_ms | 1000 | How often the late-attach scanner looks for matching processes. Bounded to 100–10000 ms. |
refresh_interval_secs | 5 | How often targets are re-resolved and matching processes are attached. Bounded to 1–300. |
max_profiles | 32 | Profile ceiling, 1–32. Lowering it prevents new profiles above the new limit. |
log_level | info | error, warn, info, debug, or trace. |
probe_interval_ms | 2000 | How often ordered links are probed for failover. Bounded to 500–60000 ms. |
probe_target | 1.1.1.1:443 | IPv4 address:port used for link probes. |
probe_target6 | [2606:4700:4700::1111]:443 | IPv6 target for link probes; only used when the profile carries IPv6 and a link has a global IPv6 source address. |
Unknown keys are rejected. The file is parsed with deny_unknown_fields, so a typo such as poll_interval stops the daemon instead of being silently ignored. Check the journal after editing:
sudo systemctl restart netscoped
systemctl status netscoped
journalctl -u netscoped -n 50 --no-pager
systemctl reload is not used. Editing the file requires a restart, which also re-reads the profile state.
Changing the socket or state path
Both paths are created and owned by the daemon. If you move socket_path, update the directory’s permissions so the netscope group can reach it, and restart the daemon. If you move state_path, move the existing state file first or the daemon starts with no profiles.
The CLI accepts a hidden --socket <path> override. It exists for tests and for a daemon started with a non-default path; it is not needed in normal use.
Changing the cgroup root
cgroup_root must remain under /sys/fs/cgroup, because the nftables selector is built from the path relative to the cgroup mount. A value outside the cgroup2 hierarchy is rejected at startup. If you change the cgroup root while profiles are active, the old directory is left behind; stop the daemon, remove the old directory, and start it again.
State file
state_path holds a versioned JSON document with one entry per profile. It stores the target kind and identity — a native executable path, a Windows PE path for a --wine profile, a process name for a --process profile, or a Steam app ID — each consumer’s claim and policy, the profile’s mark, table, priority, and index, pre-open fanotify marks, and timestamps. Version 1 profiles are migrated on load: their single mode becomes the netscope-cli consumer’s policy with a persistent claim. Peer claims belong to a live control connection and are dropped on load, so a crashed consumer cannot leave a scope claimed forever. A Wine or name profile records the canonical PE paths with device and inode, so the daemon can re-stat the files and move a fanotify mark when a game updates. The daemon owns it:
- Do not edit it while
netscopedis running. - Back it up before an upgrade if you have profiles you cannot recreate easily.
- A state version newer than the daemon is rejected rather than partially loaded.
- Deleting it while the daemon is stopped removes all profile definitions. The host rules can be cleaned with
sudo netscope cleanup --allbefore starting the daemon again.
Traffic counters and rates
Each profile’s nftables table carries six named counters: one pair for
outbound packets that reach the profile, one pair for inbound packets, and
counters for routed and dropped traffic. The daemon samples them once per
second with a single nft -j list ruleset call and keeps the totals and
smoothed rates in memory; status and list only read that cache.
- Totals are a counter window that starts at
since_unix, the daemon’s start time; every profile reports the same value. A daemon restart starts a new window, and the first sample after it adopts the kernel counters as a baseline, so traffic written before the restart is not reported as new. - Loopback is accepted before the counter selectors in both directions:
outbound
127.0.0.0/8and::1/128destinations, inbound the same source ranges. Local traffic therefore never inflatestx_bytes,rx_bytes, or theTRAFFICrates. - Recreating a table resets its kernel counters. The daemon treats the new value as the next delta, so reported totals never move backwards; rates can dip for a sample.
- Disabling a profile removes its table, so
statsbecomesnull; enabling it installs a fresh table that counts from zero while the daemon’s totals keep their previous value plus the new traffic. - Nothing is persisted; the totals and rates live in memory for the daemon’s lifetime.
Limits
- At most 32 profiles, regardless of
max_profiles. Loweringmax_profilesdoes not delete existing profiles, but adding beyond the limit fails. - Profile IDs are 1–32 characters:
[a-z0-9][a-z0-9-]*. - Marks, tables, and rule priorities are allocated from fixed ranges. Exhausting the 32 indexes fails the add. A route table used by an active profile is refused; a table left by a removed profile is cleaned up on the next daemon reconcile.
- One shared profile has one route policy and any number of filter policies, one per consumer. Policies are replaced with
netscope policy; the target itself is not edited in place.
Logging
Runtime logs go to the journal:
journalctl -u netscoped -f
netscope logs -n 100
netscope logs is a bounded view (default 50 lines, -n to change it). It does not follow. Raw daemon logs may include profile paths and interface names but never packet contents.
Example: quieter polling
For a machine with many processes, reduce wakeups by polling less often. Pre-exec attachment is unaffected; only late attachment gets slower.
[daemon]
poll_interval_ms = 2000
refresh_interval_secs = 10
log_level = "warn"
Restart after the change and confirm with sudo netscope doctor.
CLI reference
netscope is the unprivileged command-line client. It talks to netscoped over a Unix socket and never manipulates nftables or routing itself, except for the explicit cleanup recovery command.
netscope [GLOBAL OPTIONS] <COMMAND>
Global options
| Option | Meaning |
|---|---|
--socket <path> | Hidden. Override the control socket. For tests and non-default daemon paths. |
--uid <n> | Hidden, root-only. Target user ID for add. When omitted by root, SUDO_UID is used. |
-h, --help | Print help. |
-V, --version | Print the client version. |
A regular user in the netscope group does not pass --uid; their profile belongs to the connecting UID. Root passes --uid (or relies on SUDO_UID) to create a profile for another user; root add with neither fails with exit 2. A non-root user cannot use --uid.
Exit codes
| Code | Meaning | Typical message |
|---|---|---|
0 | Success. | — |
1 | Runtime or daemon error, including invalid daemon JSON, API version mismatch, nftables/routing/cgroup failure, an unknown profile, a duplicate profile, the profile limit, a daemon policy rejection that is not a permission error, or a failed doctor check. | netscope: <error> |
2 | Usage or validation error caught by the client: bad flag combination, invalid profile ID, invalid CIDR, invalid gateway, unresolvable --exe or --wine, a --process value that is empty or contains a path separator, invalid Steam app ID, --no-redact without --acknowledge, or a non-root --uid. | netscope: <error> |
3 | Daemon unavailable, or cleanup attempted while the daemon is still running. | netscope: daemon not running; start with: sudo systemctl start netscoped |
4 | Permission denied, a profile owned by another user, a target_uid that does not match the caller, or support-bundle invoked without root. | netscope: permission denied; add your user to the netscope group: sudo usermod -aG netscope $USER |
support-bundle without root exits 4 with netscope: support-bundle requires root: sudo netscope support-bundle, unless --no-redact is given without --acknowledge, which exits 2 first. A non-root doctor fails its root check and exits 1, not 4.
Scripts should branch on 0 versus non-zero, and use 3 to distinguish “daemon down” from a rejected request.
status
netscope status [--json]
Prints the daemon version, pid, kernel, cgroup root, and one row per profile. The text form has a daemon header and the same columns as list:
DAEMON netscoped 0.3.0 pid 1234 kernel 6.11.0-arch1-1
CGROUP /sys/fs/cgroup/netscope
NAME MODE ENABLED TARGET ATTACHED CONSUMERS POLICIES TRAFFIC PIDS
blocked filter yes /usr/bin/curl pre-exec cli cli:filter ↓ 0 B/s ↑ 2.1 kB/s 4242
game route yes wine:/games/Game/Game.exe pre-open cli cli:route ↓ 3.2 MB/s ↑ 420 kB/s 5150
running route yes name:Overwatch.exe late cli cli:route ↓ 840 kB/s ↑ 96 kB/s 6120
shared route yes name:Overwatch.exe pre-exec dropship,exitlink exitlink:route — 7001
TRAFFIC shows smoothed download (↓) and upload (↑) byte rates for the
profile: counts are seen_* (client to network) for upload and rx_*
(inbound) for download. The column is — when the profile has no counters —
it is disabled, removed, or has no policy, or the daemon predates them. Rates
are sampled once per second and decay toward zero while idle; --json keeps
the full counters in stats.
A profile with ordered links prints one indented line per link after its row:
link <iface> <gateway> <phase> <rtt> <active>, where - stands in for a
missing gateway or RTT and active marks the link currently carrying the
default route. The state path and config path are available in --json output.
ATTACHED is pre-exec when only the native exec hook placed processes, pre-open when only the Wine open hook did, and late when only the poll fallback did. It is mixed when a pre-attach source (pre-exec or pre-open) and the poll fallback are both present, and none when no process is attached. A Wine profile shows its target as wine:<path>; a process-name profile shows name:<name>. PIDS is a comma-separated list.
status exits 3 when the daemon is not reachable.
list
netscope list [--json]
Prints the profile table without the daemon header. Disabled profiles are included. list also exits 3 when the daemon is unavailable.
add
netscope add <name> --exe <path> (--filter <drop|allow> [CIDR OPTIONS] | --route <iface> [ROUTE OPTIONS] | --link <spec> [ROUTE OPTIONS])
netscope add <name> --wine <path> (--filter <drop|allow> [CIDR OPTIONS] | --route <iface> [ROUTE OPTIONS] | --link <spec> [ROUTE OPTIONS])
netscope add <name> --process <name> (--filter <drop|allow> [CIDR OPTIONS] | --route <iface> [ROUTE OPTIONS] | --link <spec> [ROUTE OPTIONS])
netscope add <name> --steam <appid> (--filter <drop|allow> [CIDR OPTIONS] | --route <iface> [ROUTE OPTIONS] | --link <spec> [ROUTE OPTIONS])
Creates a profile. Exactly one of --exe, --wine, --process, and --steam is required, and exactly one of --filter, --route, and --link is required.
| Option | Applies to | Meaning |
|---|---|---|
--exe <path> | Native apps | Executable to select. Resolved against the CLI’s current directory, then canonicalized. |
--wine <path> | Wine/Proton apps | Windows PE to select. Must be an absolute path. The daemon canonicalizes it and requires an MZ header. |
--process <name> | Running apps | Process name or argv basename. No install path. Matches comm and command-line basenames case-insensitively; a trailing .exe is optional. |
--steam <appid> | Steam apps | Numeric Steam app ID. Matches the app and its children. |
--filter <drop|allow> | Filter profiles | Default action. |
--allow-cidr <cidr> | --filter drop | Destination to keep reachable. Repeatable. |
--deny-cidr <cidr> | --filter allow | Destination to drop. Repeatable; at least one is required. |
--route <iface> | Route profiles | Tunnel interface. |
--link <spec> | Route profiles | Ordered link: <iface>, <iface>@<ipv4>, <iface>#<ipv6>, or <iface>@<ipv4>#<ipv6>. Repeatable, up to 8; the first healthy link carries the default route. The canonical place for a per-link gateway. Conflicts with --route. |
--gateway <ipv4> | --route | IPv4 next hop. Legacy single-link form of --link <iface>@<ipv4>. |
--gateway6 <ipv6> | --route --allow-ipv6 | IPv6 next hop. Legacy single-link form of --link <iface>#<ipv6>. |
--include-lan | --route | Route LAN destinations instead of bypassing them. |
--allow-ipv6 | --route | Carry IPv6; without it, IPv6 is dropped. |
--udp | --route | Mark only IPv4 UDP; TCP keeps its normal route. |
--except-port <port> | --route --udp | UDP destination port that stays direct. Repeatable, up to 32. |
--except-sport <port> | --route --udp | UDP source port pinned direct. Repeatable, up to 64. |
--except <cidr> | --route | Destination that stays direct. Repeatable, up to 30. |
Rules enforced before the daemon is asked:
- Name:
^[a-z0-9][a-z0-9-]{0,31}$. - Exactly one target:
--exe,--wine,--process, or--steam. Combining them is a usage error. --winetakes an absolute path to a regular file that starts withMZ. The daemon canonicalizes the path and stores its device and inode.--processtakes a name, not a path. It is matched against the processcommand its executable basename, and against any argv basename of a Wine loader;Overwatch,Overwatch.exe, andoverwatch.exeare the same target. A new Wine/Proton launch is caught by the loader hold before its first socket; an already-running process attaches on the poll and has its existing sockets marked.--allow-cidris rejected with--filter allow.--deny-cidris rejected with--filter drop, and--filter allowneeds at least one.--linkconflicts with--route,--gateway, and--gateway6; per-link gateways belong inside the spec. Interfaces must be unique and at most eight are accepted.- An IPv6 half (
#<ipv6>) requires--allow-ipv6. --gatewayis rejected outside--route.--gateway6is rejected without--allow-ipv6.--include-lan,--allow-ipv6,--udp,--except-port,--except-sport, and--exceptare accepted with either--routeor--link.--except-portrequires--udpand takes 32 ports; port0is rejected.--except-sportrequires--udpand takes 64 ports; port0is rejected. Pinned sockets are never marked and their packets are accepted before the mark rule, so a flow that already existed when the profile opened keeps its normal route. A socket keeps its source port for its whole life, so the pin holds until the process exits.--excepttakes up to 30 CIDRs. The daemon installs each as anip ruletomainat a lower priority than the mark rule.
A gateway inside the link spec is the canonical form. --route eth0 --gateway 192.168.1.1 (and --gateway6 with --allow-ipv6) remains accepted for
compatibility and is stored as a single link with the same interface and
gateway fields, so it is equivalent to --link eth0@192.168.1.1. The two
spellings cannot be mixed in one command: --link conflicts with --route,
--gateway, and --gateway6.
On success the daemon creates the cgroup, the nftables table, and, for route profiles, the policy rule and routes. If the app was already running and got attached late, the CLI prints:
existing sockets marked; restart is not required
sudo netscope --uid "$(id -u)" add blocked --exe /usr/bin/curl --filter drop
sudo netscope --uid "$(id -u)" add routed --exe /usr/bin/game --route tun0 --include-lan
sudo netscope --uid "$(id -u)" add game --wine /games/Game/Game.exe --filter drop
sudo netscope --uid "$(id -u)" add running-game --process Overwatch.exe --route tun0
sudo netscope --uid "$(id -u)" add game-udp --process Overwatch.exe --route tun0 --udp --except-port 53
sudo netscope add steam-game --steam 730 --filter drop --allow-cidr 203.0.113.0/24
The last example assumes the user is in the netscope group and has re-logged in. On success, add prints added <name>; with a late attach it prints the restart warning on the next line.
For a Wine target the profile attaches when a Wine process opens the PE, which is before the game runs. Launch the game the same way you would without netscope — there is no wrapper. A --process target holds the Wine/Proton loaders and attaches a new launch before its first socket; an already-running process attaches on the poll and is reported as late. See Wine and Proton targets and Name targets.
open, policy, close, and resolve
netscope open --consumer <id> [--name <profile>] <TARGET> [POLICY]
netscope policy <profile> --consumer <id> (--filter ... | --route ... | --link ...)
netscope close <profile> --consumer <id>
netscope resolve (--profile <id> | <TARGET>)
open creates or joins a shared profile for (target, target uid). Two
consumers with the same target and uid get the same profile and cgroup. The
first consumer creates the scope; the last close removes it. --name names a
new profile; without it the daemon generates one from the target and uid (for
example overwatch-u1000). The CLI holds its claim persistently, so it
survives the command. Peer claims belong to API clients that keep their control
connection open; they are not available from the one-shot CLI. The target flags
are the same as add, and a policy may be attached in the same command.
policy replaces one consumer’s policy. A shared profile can carry several
filter policies but only one route policy; a second consumer asking for a route
gets an error. Filters run before the route mark, so a drop
from any consumer is terminal for that packet and an accepted packet continues
to the route mark.
close releases one consumer and reports whether the profile was removed.
resolve finds the shared profile from an id or a target and prints it. If
open succeeds but its policy is rejected, the claim is kept: retry
netscope policy, or netscope close it.
sudo netscope --uid "$(id -u)" open --consumer exitlink --name overwatch --process Overwatch.exe --route tun0
sudo netscope --uid "$(id -u)" open --consumer dropship --process Overwatch.exe --filter allow \
--deny-cidr 64.224.26.0/23
sudo netscope policy overwatch --consumer exitlink --route tun0 \
--except 203.0.113.0/24
sudo netscope --uid "$(id -u)" open --consumer exitlink --name failover \
--process Overwatch.exe --link enp7s0f4u1 --link wwan0@10.64.0.1
sudo netscope close overwatch --consumer dropship
See Shared scopes.
remove
netscope remove <name>
Deletes the profile’s nftables table, policy rule, routes, and cgroup, then prints removed <name>. Removing a profile that does not exist is a daemon error (1). Removing another user’s profile as a non-root user is 4.
enable and disable
netscope enable <name>
netscope disable <name>
disable stops enforcement for the profile while keeping its definition and cgroup; enable restores it. Disabling a route profile removes its mark rules, so its traffic returns to the main table. Both print enabled <name> or disabled <name> and validate ownership like remove.
refresh
netscope refresh
Forces an immediate target re-stat and attach pass instead of waiting for refresh_interval_secs. It re-resolves each --exe or --wine target’s device and inode, attaches already-running matching processes (including name targets), then prints refreshed. For a Wine target whose file changed, it moves the fanotify mark to the new inode. It does not repair a deleted nftables table; the daemon’s health loop does that.
doctor
netscope doctor [--json]
Runs eight checks: root, kernel, cgroup v2, tools (nftables and iproute2), daemon (connectivity and API version), control socket (group netscope, mode 0660, connectable), state + config, and profiles (per-profile runtime). It exits 0 only when no check fails; warnings do not change the exit code.
A non-root run fails the root check and exits 1. Other privileged probes that need root report SKIP. Run it with sudo netscope doctor for the complete report.
--json prints a machine-readable report. It is suitable for a bug report and is also included in the support bundle.
support-bundle
netscope support-bundle [--output <path>] [--include-logs] [--include-config]
netscope support-bundle --no-redact --acknowledge
Collects diagnostics into a compressed archive. It is root-only; a non-root run exits 4, except --no-redact without --acknowledge, which exits 2 before the root check. Without --output, it writes /tmp/netscope-support-<UTC>.tar.zst.
logs.txt and config.toml are excluded unless you pass --include-logs and --include-config. Redaction is on by default; --no-redact is refused unless --acknowledge is also given, and is intended only for a private reproduction.
On success it prints:
support bundle: <path>
review before uploading
The bundle is mode 0600 and chowned back to SUDO_UID:SUDO_GID under sudo; review it before uploading. See Support bundle.
logs
netscope logs [-n <lines>]
Prints the most recent daemon log lines from the journal. The default is 50; -n changes the count. It does not follow, and it does not clear the journal.
cleanup
netscope cleanup --all [--purge]
Recovery command for host state left behind by a daemon that could not clean up. It is root-only, requires --all, and bypasses the daemon. If the daemon is reachable, it refuses and exits 3:
cleanup requires the daemon stopped: sudo systemctl stop netscoped
--purge additionally deletes the configured state file (/var/lib/netscope/state.json by default) and /etc/netscope/config.toml. Without it, only runtime state is cleaned: every netscope_* nftables table, every policy rule and flushed route table in the 20000–20031 range, and every profile cgroup under the configured cgroup root. The profile definitions therefore survive and are re-applied when the daemon starts. Cleanup never deletes the control socket, so a stopped daemon cannot be contacted through it.
On success it prints cleanup complete. A failed cleanup step prints the step and exits 1.
sudo systemctl stop netscoped
sudo netscope cleanup --all
sudo systemctl start netscoped
--json output
status, list, and doctor accept --json. The client prints the response’s data field only, pretty-printed, with no success envelope and no error field. Errors are still printed to standard error with the exit code above.
Examples by outcome
| Command | Exit | Why |
|---|---|---|
netscope status | 0 | Daemon reachable. |
netscope status with the service stopped | 3 | No socket. |
netscope add bad_name --exe /bin/true --filter drop | 2 | Uppercase and underscore are invalid in a profile ID. |
netscope add x --exe /bin/true --filter allow | 2 | allow needs at least one --deny-cidr. |
netscope add x --exe /nope --filter drop | 2 | Path cannot be resolved. |
netscope add x --exe /bin/true --route tun9 | 1 | The daemon cannot install a route through a missing interface. |
netscope add x --exe /bin/true --filter drop as a non-group user | 4 | Socket permission denied. |
sudo netscope cleanup --all while netscoped runs | 3 | Cleanup requires the daemon stopped. |
netscope-gui
netscope-gui is the desktop inspector for netscoped. It shows the links netscoped is using,
the live rates it carries, and the scopes it holds, and it can add, enable, disable, and remove
scopes without a terminal.
netscope-gui
The window connects to the daemon’s control socket. If the daemon is not running, the window says
so, keeps retrying every two seconds, and links to Doctor for the check and the exact
systemctl start netscoped command. If the caller is not in the netscope group, the window
shows the usermod -aG netscope hint instead of pretending the daemon is absent.
What the window shows
- Overview — the total send and receive rates, a short rate history, one card per link with RTT and last-ok age, recent activity, and anything that needs attention.
- Scopes — one profile at a time: mode, target, cgroup, mark, policy claims, peer hold, and the actions the current permission level allows.
- Add scope — a running process or an executable, a filter or ordered-route policy, and a peer or persistent hold.
- Doctor — the same checks as
netscope doctor, each with its fix command ready to copy.
Pause
Pause updates freezes the numbers only. netscoped keeps enforcing and carrying traffic; the paused tooltip says so. Resume with the rail button or the tray menu.
Tray
The tray icon is dev.qiiks.netscope and offers Show netscope, Pause updates, and
Quit. Its tooltip carries the current total throughput. Linux shows the icon where a tray
host runs (KDE, waybar, most bars; GNOME needs an extension).
Demo mode and screenshots
--demo opens the window with a fixed demonstration snapshot. Normal launches never read it.
--snap renders a headless PNG and also uses the demonstration snapshot:
netscope-gui --snap /tmp/overview.png --scene overview --theme dark --size 1100x720
netscope-gui --snap /tmp/narrow.png --scene narrow --size 900x600
Scenes: overview, scopes, add, doctor, empty, down, denied, narrow.
Scope claims
The window opens peer holds by default: the daemon releases them when this window’s control connection closes, including when the window is closed. A persistent hold survives the window and a daemon restart and can be released from Scopes.
Security
netscope is alpha software and has not received an independent security audit. Treat it as a convenience tool for scoping one application’s traffic, not as a hardened security boundary against a hostile local user or a compromised kernel.
Reporting a vulnerability
Report privately through GitHub Security Advisories. Do not open a public issue for a vulnerability.
Include the netscope version, distribution, kernel, steps to reproduce, and whether the daemon runs under systemd or manually. A reviewed support bundle helps; see Support bundle. We aim to acknowledge a report within seven days and to allow a fix and release before public disclosure.
Trust boundary
netscoped runs as root. It must, because it creates cgroups, opens a fanotify permission group, and invokes nft and ip. The privileged surface is deliberately small:
| Component | Privilege | Exposure |
|---|---|---|
netscoped | root | Unix socket, fanotify, child nft/ip processes |
netscope CLI | user | Unix socket |
| Kernel | — | cgroup v2, fanotify, nftables, policy routing |
The daemon opens one Unix socket at /run/netscope/control.sock. It does not open a TCP or UDP listener, does not serve HTTP, and has no remote UI, telemetry, or analytics.
The socket is owned by root:netscope with mode 0660. Only root and members of the netscope group can connect. Joining that group is equivalent to trusting the user to manage profiles for their own UID; do not add untrusted accounts.
Authentication and authorization
There are no passwords or tokens. The daemon uses the peer credentials of the Unix socket connection:
- A member of the
netscopegroup can add and manage profiles owned by their own UID. - Root can manage any profile and can pass
--uidto create one for another user.--uidis refused for non-root callers. - A profile records its owner UID. A non-root user cannot remove, enable, or disable another user’s profile.
- The CLI does not send a claimed UID that the daemon blindly trusts; ownership comes from the socket peer, except for the root-only
--uidpath.
Input validation
All requests are JSON lines with a 256 KiB request limit and a 256 KiB response limit. The daemon rejects malformed JSON, oversized requests, and unknown operations. The CLI checks the status response’s API version and refuses a daemon with a different protocol version; the current version is 2.
Before any host change, the daemon validates:
- Profile IDs: 1–32 characters,
[a-z0-9][a-z0-9-]*. The ID is used in cgroup and nftables table names, and-is mapped to_for nftables. - Executable paths: canonicalized. The daemon records the device and inode and matches on that identity, re-resolving it when the file at the recorded path changes.
- Windows PE paths:
--winetakes an absolute path. The daemon canonicalizes it, requires a regular file, and requires the first two bytes to beMZbefore recording its device and inode. No ELF check is applied. - Process names:
--processtakes a name, not a path; it is rejected if empty, longer than 64 printable characters, or contains a path separator. The daemon refusesnetscope,netscoped, and the sharedwineserveras name targets. - Steam app IDs: numeric, within the profile target type.
- CIDRs: parsed as IPv4 or IPv6 networks. Invalid input is rejected, not passed through.
- Interfaces and gateways: passed as separate arguments to
ip; no shell is involved. - Numeric values: profile count is bounded at 32, and index, mark, table, and priority allocation is bounded.
- Shared scopes: every consumer must resolve to the profile’s owner UID, and a non-root caller cannot name another UID as
target_uid. One route policy is allowed per profile; a second consumer’s route request is refused. Filter policies compose, so a consumer cannot remove another consumer’s filter by replacing its own policy.
The daemon never invokes a shell, never evaluates a profile-controlled string as shell code, and never writes attacker-controlled text into an nftables rule without parsing or a fixed grammar.
Root daemon risks
A root daemon is a real risk if it has a bug. Reduce exposure:
- Keep the
netscopegroup small. Membership is a privilege. - Keep
/etc/netscope/config.tomland/var/lib/netscope/state.jsonroot-owned and not writable by group members. - Do not expose
/run/netscope/control.sockthrough a bind mount, a container, or a network filesystem. - Run the shipped systemd unit, which supervises and restarts the daemon. It sets
NoNewPrivileges=yes,ProtectSystem=strict,ProtectHome=read-only,PrivateTmp=yes,RestrictNamespaces=yes, and a bound set of system calls.ProtectControlGroups=noandProtectKernelModules=noare deliberate: the daemon must write the cgroup hierarchy, andnft_socketmay autoload on the first cgroup rule. - Review the unit’s sandboxing before changing it. It grants
CAP_SYS_ADMIN,CAP_NET_ADMIN, andCAP_SYS_PTRACE; removing one breaks attachment or rule installation. - Treat a kernel or nftables vulnerability as outside netscope’s boundary; netscope is a consumer of those interfaces.
What netscope does not protect
netscope scopes traffic from processes it successfully attaches to a profile cgroup. It does not protect:
- Sockets that cannot be marked. A socket opened before attachment is marked with the profile mark and matched by
socket mark. Marking requires the root daemon to reach the target’s descriptor table (pidfd_getfd,CAP_SYS_PTRACE); the nftables and routing rules needCAP_NET_ADMIN. If a descriptor cannot be reached, the daemon logsmark profile=... error=...and that socket keeps its original route. A--processtarget for an already-running app attaches late, so this is the case to watch for name targets; a new Wine/Proton launch is caught by the loader hold before its first socket. Restart the app if the log reports a failure. - Mark collisions. The nftables rules match any socket carrying the profile’s mark, not only sockets the daemon marked. A process with
CAP_NET_ADMINcan set the same mark on its own socket and become subject to the profile. The mark base (0x4e53) is intended to be private to netscope; another tool that uses those values can collide. - Name collisions. A
--processtarget matches every process with thatcommor argv basename, including another user’s process if it runs under the same UID. Both are attached. Use--exeor--winewhen the identity must be exact. - Non-Wine processes that open a selected PE. A
--wineprofile marks the file, so the daemon sees every open of it, but it attaches only a Wine process whose command line names the selected PE. Any other process that opens the same file — a file manager, an indexer, a scanner — is allowed and left unscoped.wineserveris never attached; it may open the PE for a loader, but it is shared across Wine applications. Children the attached process starts stay in the same cgroup. - Processes that escape attachment. A process that was never matched by the exec hook or the poller is unscoped. Privileged processes can move themselves out of the cgroup; netscope does not fight that.
- Kernel-generated traffic with no local socket. Forwarded packets, some ICMP errors, and other locally generated traffic that is not attributed to a socket in the cgroup are not matched.
- Traffic forwarded for another host. A router or NAT workload forwarding packets on behalf of another machine is out of scope.
- DNS decisions. netscope matches destinations, not names. A name that resolves to an unexpected address is filtered or routed by address.
- The tunnel itself. Route mode does not create, authenticate, or encrypt a tunnel. It sends marked traffic to an interface you configured.
- A determined local attacker with root. Root can change cgroups, nftables, and routes. netscope is not a defense against root.
Fail-open behavior
Route mode fails closed only while its rules are installed and the tunnel is down. Removing the profile, a clean daemon stop, a reboot, or manual removal of the table is fail-open: marked traffic falls through to the main routing table. See Route mode.
Filter mode is fail-open in the same operational sense: if the profile or daemon is removed, packets are no longer dropped.
Supply chain and build
- The workspace is dual-licensed MIT OR Apache-2.0.
- No dependencies are vendored; builds use crates.io through Cargo.
- Release tarballs should ship with a SHA-256 file; verify it before installing.
- CI runs formatting, Clippy, tests, a release build, and an mdBook build.
- No external assets, fonts, analytics, or tracking scripts are loaded by these docs or the binaries.
Troubleshooting
Start with the two commands that answer most questions:
netscope status
sudo netscope doctor
status shows whether the daemon is reachable and what each profile currently has attached. doctor checks seven things: root, kernel, cgroup v2, tools, daemon, state and config, and profile runtime. Both exit non-zero on failure; see CLI reference. Run doctor with sudo; without root the root check fails and doctor exits 1.
Checklist
| Symptom | Check | Likely cause and fix |
|---|---|---|
daemon not running (exit 3) | systemctl status netscoped | Start it: sudo systemctl start netscoped. If it exits immediately, read journalctl -u netscoped -n 50. |
permission denied (exit 4) | id -nG includes netscope | Add the user and log in again: sudo usermod -aG netscope "$USER". A new shell is not enough; the session must be new. |
doctor fails on the kernel | uname -r | Kernel older than 5.13 cannot hold an exec. Upgrade the kernel. |
doctor fails on cgroups | stat -fc %T /sys/fs/cgroup | Not cgroup2fs means a v1 or hybrid hierarchy. Boot with unified cgroup v2. |
nft: command not found | command -v nft | Install nftables. |
ip: command not found | command -v ip | Install iproute2. |
Operation not permitted from nftables or cgroups | systemctl cat netscoped | The daemon is not running as root or lost a required privilege. Run the shipped unit; do not drop capabilities it needs. |
| Config change is ignored | journalctl -u netscoped -n 50 | The daemon reads the file at startup. Restart it. An unknown key is rejected. |
add says the profile exists | netscope list | Names are unique. Remove the old profile or choose another name. |
add fails at the profile limit | netscope list | At most 32 profiles. Remove unused ones. |
| App is not scoped | netscope status, then cat /proc/<pid>/cgroup | The app was never matched, or the poll has not run yet. Check the target name and wait one poll interval; a late attach marks existing sockets. Restart only if the process is not matched at all. |
| App is scoped but an old connection still works | netscope status shows late | Existing sockets are marked and should follow the profile. Check the daemon log for mark profile=... error=...; restart the app if a descriptor could not be marked. |
| Steam game is not scoped | netscope status after launching the game | Add the profile before launching, and launch the game through Steam. Restart Steam if it was already running. |
| Wine/Proton game is not scoped | netscope status shows pre-open, mixed, late, or none | Point --wine at the .exe the loader actually opens, using an absolute path. Add the profile before launching. A late attach marks existing sockets, so they follow the profile; restart only if the log reports a mark failure. |
| Two consumers need one app | netscope status and netscope resolve --process <name> | Use netscope open with each consumer so both share one profile and cgroup. A second netscope add for the same target and uid is refused with target already scoped. |
| Process-name target is not scoped | netscope status shows name:<name>, cat /proc/<pid>/comm | The name must match comm or an argv basename, case-insensitively. Cross-check the real name with ps -o comm= -p <pid>. A late attach marks existing sockets, so they follow the profile; restart only if the log reports a mark failure. |
| Route profile has no connectivity | ip link show <iface>, ip route show table <table> | The interface is down or has no route. The blackhole is doing its job. Bring the tunnel up. |
| Route profile works, then dies when the VPN drops | This is intended | The blackhole rejects traffic while the interface is down. Restore the tunnel or remove the profile. |
| LAN devices unreachable in route mode | Look for --include-lan | By default LAN traffic bypasses the tunnel. If the app must reach the LAN through the tunnel, add the flag. Conversely, if you added --include-lan and the tunnel does not carry LAN, remove it. |
| IPv6 stops working | netscope status | Route mode drops IPv6 unless --allow-ipv6 is set. Add it only if the tunnel carries IPv6. |
| DNS fails in filter mode | The profile’s --allow-cidr list | Allow the resolver’s address. Loopback resolution through a local stub may still work, but the stub’s upstream query is a separate process. |
| DNS fails in route mode | Tunnel DNS configuration | netscope does not configure DNS. Push a resolver through the tunnel or set one manually. |
| Stale tables or rules after a crash | sudo nft list tables, ip rule show | Stop the daemon, then run sudo netscope cleanup --all. See Cleanup. |
cleanup exits 3 | systemctl is-active netscoped | Cleanup refuses to run while the daemon is active. Stop it first. |
The daemon will not start
systemctl status netscoped
journalctl -u netscoped -n 100 --no-pager
Common causes:
- Unknown config key. The config parser rejects unknown fields. Compare
/etc/netscope/config.tomlwith Configuration. - cgroup root outside the cgroup2 mount.
cgroup_rootmust be under/sys/fs/cgroup. - State file unreadable or from a newer version. Restore a backup or, if you accept losing profile definitions, move the file aside and run
sudo netscope cleanup --allbefore starting. - Socket path not writable.
/run/netscopemust exist and be writable by root. The shipped unit creates it.
The app is not scoped
Check in this order:
netscope listconfirms the profile exists and is enabled.netscope statusshows a process inPIDSwhile the app runs.cat /proc/<pid>/cgroupcontains/netscope/<profile>.sudo nft list table inet netscope_<profile>shows the expected rules.
If PIDS is empty, the daemon never matched the process. Typical reasons:
- The executable path resolved differently than you expect. Use
readlink -f /path/to/appand add that path. - The app is a script or launcher that execs a different binary. Select the binary that actually opens sockets, or select the launcher if its children stay in the same cgroup.
- The app was already running before the profile existed and the poll fallback did not catch it. Restart it.
- A security policy blocks fanotify.
doctorreports the attach source; late attachment is reported aslate.
If PIDS is populated but traffic still escapes, the remaining causes are usually a socket whose descriptor could not be marked (check the log), DNS handled by another process, or traffic generated by the kernel rather than the app. See Security.
Wine and Proton
A --wine profile attaches when Wine opens the PE, and status reports pre-open. If the game runs but PIDS stays empty:
netscope statusshould showwine:/path/to/game.exe. A file understeamapps/common/and its copy understeamapps/compatdata/<appid>/pfx/drive_c/are different files. Select the one Wine actually opens, and keep the path absolute.- The attached process must be a Wine process whose command line names the PE:
wine,wine64,wine-preloader,wine64-preloader, or a basename containingwine-preloader.wineserveris never attached; it may open the PE for a loader, but it is shared across Wine applications. A non-Wine process that opens the PE is allowed and left unscoped by design. - Add the profile before launching the game. If the daemon had to fall back to polling,
statusshowslate; existing sockets are marked at attach, so they follow the profile. Restart only if the log reports a mark failure. - Flatpak, Snap, and unusual filesystems are not tested. If the fanotify mark cannot be placed,
statusreportsfanotify unavailable; poll-only, and the poll fallback attaches late and marks existing sockets. It also reportsexecutable missingwhen the PE path no longer exists. Report a reproducible case with a support bundle.
Route mode has no connectivity
ip link show tun0
ip rule show
ip route show table 20000
nft list table inet netscope_<profile>
- No
default dev tun0route means the interface is down or the gateways are wrong. If the tunnel needs a next hop, pass--gatewayor--gateway6. - The blackhole route at metric 1000 is expected. It is only used when the real route is absent.
- Test from the same terminal and user that launches the app. A different process is not in the profile.
- Confirm the app is attached and that its connections were opened after attachment.
Cleanup
Use cleanup only when the daemon cannot remove its own state:
sudo systemctl stop netscoped
sudo netscope cleanup --all
sudo systemctl start netscoped
cleanup --all removes netscope’s nftables tables, policy rules, routes, and cgroups. It refuses to run while the daemon is reachable, because the daemon’s health loop would recreate the rules within about ten seconds. --purge also removes the persisted profile state and /etc/netscope/config.toml; use it only when you intend to start over.
Before reporting a bug
sudo netscope doctor --json
sudo netscope support-bundle --include-logs --include-config
Review the bundle, then attach it to the issue. Include the exact command, expected behavior, actual behavior, and the profile’s consumers and policies. See Support bundle.
Support bundle
netscope support-bundle collects the diagnostics needed to investigate a bug into one archive. It is the fastest way to give a maintainer the daemon’s view of the host without a screen-sharing session.
sudo netscope support-bundle
sudo netscope support-bundle --include-logs --include-config
support-bundle is root-only; a non-root run exits 4 with netscope: support-bundle requires root: sudo netscope support-bundle. The one exception is --no-redact without --acknowledge, which exits 2 before the root check runs. Without --output, it writes:
/tmp/netscope-support-<UTC>.tar.zst
For example /tmp/netscope-support-20261030T221500Z.tar.zst. On success it prints the path and a reminder:
support bundle: /tmp/netscope-support-20261030T221500Z.tar.zst
review before uploading
The archive is mode 0600. When run through sudo, it is chowned back to SUDO_UID:SUDO_GID so your normal user can read it. With --output <path>, the same permissions apply at the path you choose.
Options
| Option | Effect |
|---|---|
--output <path> | Write the archive here instead of /tmp/netscope-support-<UTC>.tar.zst. |
--include-logs | Include logs.txt with recent netscoped journal lines. Omitted by default. |
--include-config | Include config.toml. Omitted by default. |
--no-redact | Disable redaction. Refused unless --acknowledge is also given. |
--acknowledge | Confirm that an unredacted bundle may contain sensitive system information. |
Redaction is on by default. --no-redact without --acknowledge exits 2 before writing anything; that validation runs before the root check, so a non-root --no-redact run also exits 2, not 4.
Contents
The archive contains one top-level directory, netscope-support-<UTC>/. All members are inside it.
| Member | Contents |
|---|---|
README.txt | Review-before-upload warning and a statement that the bundle is never uploaded automatically. |
manifest.json | Machine-readable manifest: generated_by, generated_at, files, redaction settings and labels, and excluded categories. |
version.txt | netscope and netscoped versions, API version, and build identity. |
doctor.txt | The human-readable netscope doctor report. |
doctor.json | The same report as JSON. |
status.json | Daemon status and profile status, including attach source and PIDs. Present when the daemon is reachable. |
list.json | The daemon’s profile list response. Present when the daemon is reachable. |
system.txt | Kernel, distribution, architecture, cgroup mode, and tool versions. |
service.txt | systemctl status netscoped and the unit’s effective state. |
logs.txt | Recent journal lines for netscoped. Present only with --include-logs. |
nft.txt | The netscope_* nftables tables only, not your full ruleset. |
ip.txt | ip rule and the netscope route tables. |
cgroups.txt | The netscope cgroup hierarchy and profile membership. |
config.toml | The daemon configuration. Present only with --include-config. |
state.json | The profile state file, when it exists. |
profiles/ | Report files under the state directory’s profiles/ tree, when present. |
late/ | Report files under the state directory’s late/ tree, when present. |
errors.txt | Present only if a collection step failed or reported a warning; lists which step and why. |
What is deliberately excluded
The bundle does not include:
/proccontents, shell history, and arbitrary home-directory files;/etc/passwd,/etc/shadow, or any account database;- SSH keys or other private keys;
- environment dumps;
- packet captures;
- browser data;
- core dumps;
- your complete nftables ruleset or complete routing table.
Redaction
Before writing the archive, the collector replaces sensitive values:
| Value | Replaced with |
|---|---|
Your home directory ($HOME, /home/<user>, /root) | <home> |
| Username | <user> |
| Hostname | <host> |
| Profile names | <profile-1>, <profile-2>, … |
| Secret-key values in config or state | [REDACTED] |
| JWT and long hexadecimal/base64-looking tokens | <redacted> |
| MAC addresses | [MAC] |
| Link-local IPv6 addresses | <link-local> |
Redaction is best effort. Paths outside your home directory, interface names, and most IPv4 and IPv6 addresses remain because they are usually necessary to diagnose routing and attachment problems. Profile names are replaced with numbered placeholders. Review the archive before uploading it.
Review before uploading
Extract to a temporary directory and read the text files:
mkdir -p /tmp/netscope-review
tar --zstd -xf /tmp/netscope-support-*.tar.zst -C /tmp/netscope-review
ls -la /tmp/netscope-review/netscope-support-*/
Check at least:
README.txt,manifest.json, anderrors.txtfor what was collected or failed;state.jsonandconfig.tomlfor paths or names you do not want public;logs.txtfor unrelated messages from other services;ip.txtandnft.txtfor addresses or interface names you consider private.
config.toml and logs.txt are absent unless you passed --include-config and --include-logs.
Remove the archive and the extracted directory when you are done:
rm -rf /tmp/netscope-review /tmp/netscope-support-*.tar.zst
Attaching to an issue
Attach the .tar.zst file to the bug report. Include:
- the exact command that failed, copied verbatim;
- what you expected and what happened;
- which consumers and policies the profile carried, and whether it routed or filtered;
- whether the app was already running when the profile was added.
The bug report form asks for these. For a security issue, do not attach the bundle to a public issue; use the private advisory link in Security.
If collection fails
errors.txt names the step that failed. Common causes:
- the daemon is not running, so status and logs are incomplete;
/tmpis full or mounted noexec;- the journal is not readable.
The archive is still written with whatever was collected. Fix the cause and rerun if the missing part matters.
Distro notes
netscope needs a modern kernel, unified cgroup v2, nftables, iproute2, and systemd. These notes describe what usually works. The project’s CI runs on Ubuntu 24.04; the AUR package and the release tarball are the only packaged channels in v0.3.0, and the release binary is x86_64.
Kernel configuration
These kernel options are easy to miss on custom kernels:
grep -E 'CONFIG_(FANOTIFY|FANOTIFY_ACCESS_PERMISSIONS|NFT_SOCKET)=' "/boot/config-$(uname -r)"
If your kernel exposes /proc/config.gz instead, use zgrep on it.
CONFIG_FANOTIFYandCONFIG_FANOTIFY_ACCESS_PERMISSIONSare required for pre-exec attachment.CONFIG_NFT_SOCKETis required for thesocket cgroupv2match.
If NFT_SOCKET is missing, filter and route rules cannot match the profile cgroup. If fanotify permission events are missing, only late attachment is possible and doctor reports it.
Arch Linux and CachyOS
Use the AUR package:
yay -S netscope-bin
sudo systemctl enable --now netscoped
sudo usermod -aG netscope "$USER"
nftables and iproute2 are normally installed; install them if doctor reports them missing. If doctor reports a missing fanotify permission event, check the kernel options above.
Fedora
Fedora has cgroup v2 by default, a recent kernel, nftables, and systemd. There is no official package in v0.3.0; install the release tarball.
With SELinux enforcing, a denial can block fanotify permission events or writes under /sys/fs/cgroup. Check the audit log before changing policy:
sudo ausearch -m avc -ts recent
journalctl -u netscoped -n 50 --no-pager
Report a denial that reproduces with the shipped unit. Do not disable SELinux globally to work around it.
Debian and Ubuntu
- Debian 12 (bookworm): kernel 6.1, cgroup v2 by default. Works with the tarball.
- Ubuntu 22.04 and newer: kernel 5.15 or newer, cgroup v2 by default. Works with the tarball.
- Debian 11: kernel 5.10 is too old for pre-exec attachment. Upgrade the kernel or use a newer release.
- Ubuntu 20.04: kernel 5.4 is too old. Upgrade.
Use the tarball; there is no Debian package in v0.3.0.
openSUSE Tumbleweed
Recent kernels and cgroup v2 are standard, and firewalld uses the nftables backend. Install the tarball. If a firewall frontend flushes unmanaged tables on reload, the daemon’s health loop recreates netscope’s tables within about ten seconds; a frontend that removes them continuously is a configuration conflict worth reporting.
RHEL and compatible distributions
RHEL 9 and compatible rebuilds ship kernel 5.14 or newer with cgroup v2 and nftables. Install the tarball. Older RHEL 8 kernels are too old.
NixOS
There is no NixOS module or package in v0.3.0. The tarball can be installed manually, but the unit and sysusers file are not declarative. A proper module would need to express the group, unit, config, and state directory; it is not included yet.
Alpine and other non-systemd distributions
Unsupported. netscope’s service management, socket directory, and state directory assume systemd. The binaries may run under another supervisor, but that path is not tested.
Containers and WSL
- Containers: unsupported. netscope needs to create cgroups on the host and match the host cgroup hierarchy. Running it inside a container requires a privileged container with the host cgroup namespace, which defeats most of the isolation the container provides.
- WSL2: unsupported. The WSL kernel and init may not expose fanotify permission events or the cgroup hierarchy the same way a normal systemd host does.
32-bit and ARM
The v0.3.0 release tarball is x86_64 only, and install.sh rejects other architectures. The workspace can be built from source on other architectures, but the installer will not run there; copy the binaries, unit, sysusers entry, and config into place manually. The kernel features, not the CPU, determine whether the result works.
Firewall coexistence
netscope adds its own inet tables named netscope_*, plus ip rule entries and tables in the 20000–20031 range. It does not modify your existing firewall tables or the main routing table.
If another tool flushes all nftables tables or all routing rules, profiles break until the daemon’s health loop recreates them, within about ten seconds. If the other tool removes them continuously, the two tools are incompatible on that host.
Contributing
Contributions are welcome. netscope is alpha software with a deliberately small scope: per-application filtering and routing on Linux with cgroups, nftables, and policy routing.
Licensing and sign-off
netscope is dual-licensed under MIT OR Apache-2.0. Contributions are accepted under the same terms.
Every commit must carry a Developer Certificate of Origin sign-off:
git commit -s
The sign-off certifies the Developer Certificate of Origin 1.1. Use your real name and an email address you can receive mail at. A pull request with unsigned commits is not merged.
Set up
You need a Linux host with kernel 5.13 or newer, cgroup v2, nftables, iproute2, systemd, and a Rust toolchain. The repository’s rust-toolchain.toml selects the toolchain; rustup honors it automatically.
git clone https://github.com/Qiiks/netscope
cd netscope
cargo build --workspace
mdbook build docs
The workspace has three crates:
crates/netscope-linux— the reusable engine: cgroups, nftables, policy routing, fanotify, and process scanning.crates/netscope-daemon— the root daemon: JSON-lines socket, reconciliation, health loop, attach provenance.crates/netscope-cli— the unprivileged CLI and diagnostics.
The public types live in crates/netscope-linux/src/types.rs and protocol.rs. They are a stable interface for downstream consumers; change them deliberately.
Checks
Run the same checks CI runs:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets
cargo test --workspace
cargo build --release --workspace
mdbook build docs
CI runs Clippy warn-only while the existing baseline is paid down. A change that
adds a warning is not merged; do not silence a new warning with a blanket
allow.
Privileged changes
Some behavior can only be verified as root: cgroup attachment, nftables rule installation, policy routing, and the fanotify exec hook. CI runs unprivileged, so a change that touches those paths needs a manual verification note in the pull request: the commands you ran, the kernel and distribution, and the observed result. The integration suite is:
sudo cargo test -p netscope-daemon --test privileged -- --ignored --test-threads=1
Do not run privileged tests on a host with a live netscope service; stop it first, and inspect netscope_* nftables tables, 20000–20031 route tables, and the profile cgroups afterward.
Pull requests
- Keep the change focused. Separate refactors from behavior changes.
- Add a test for a behavior change, or explain why it cannot be tested without root.
- Include the commands you ran to verify the change.
- Update the docs in
docs/srcwhen behavior or the CLI changes. The docs are part of the change. - Do not add telemetry, remote analytics, a hosted dashboard, or a network listener to v0.2.x.
- Do not add a wrapper,
LD_PRELOADshim, or proxy auto-configuration. The no-wrapper property is a design constraint. - Keep error messages actionable and free of secrets. UI text must not expose tokens or internal paths.
Commit messages
Use the imperative mood and a short subject:
Add IPv6 blackhole check to route reconciliation
Explain why the check is needed and what changed. Wrap the body at 72
columns. Reference an issue with "Fixes #123" when applicable.
Sign off with git commit -s.
Reporting bugs and security issues
- Bugs: use the bug report form and attach a reviewed support bundle.
- Features: use the feature request form.
- Vulnerabilities: do not open a public issue. Use GitHub Security Advisories.
FAQ
Why does netscope need root?
netscoped creates cgroups under /sys/fs/cgroup, opens a fanotify permission group, and invokes nft and ip. All of those require privilege. The CLI itself is unprivileged and talks to the daemon over a Unix socket; only the daemon is root.
If you do not want a root daemon, do not run netscope. There is no unprivileged mode.
Why not eBPF?
eBPF could implement similar filtering, but it adds a verifier, program loading, kernel-version-specific bytecode, and a larger privileged surface. cgroup v2 plus nftables already provides a stable socket-to-cgroup match on the kernels netscope targets, with rules that are easy to inspect using nft list. netscope loads no BPF program and no kernel module.
This is a scope decision, not a claim that eBPF is unsuitable for every workload.
Why kernel 5.13?
5.13 is the first kernel with FAN_OPEN_EXEC_PERM, the fanotify permission event that lets the daemon hold an exec while it moves the process into the profile cgroup. Without it, only late attachment is possible and the first sockets can escape. netscope targets 5.13 and newer rather than shipping a weaker guarantee on older kernels.
Is there a wrapper?
No. You launch the application normally. There is no netscope run -- app, no LD_PRELOAD, and no proxy environment variable to set. A native app is selected at exec through fanotify; a Windows PE is selected when Wine opens it; a name target holds the Wine/Proton loader and catches a new launch before its first socket, and the poller catches an already-running process late.
Can I select a running app by name without a path?
Yes. --process <name> attaches a running process by its comm or an argv basename, with no install path and no wrapper:
sudo netscope --uid "$(id -u)" add game --process Overwatch.exe --route tun0
Matching is case-insensitive and a trailing .exe is optional. A new Wine/Proton launch is caught by the loader hold before its first socket; a process that was already running is attached on the next poll, and the sockets it already held are marked with the profile mark at the same time, so their next packets follow the profile too. A restart is not required.
What about applications that are already running?
They are attached late by the poller, if they match. Sockets that were opened before the attach are marked with the profile mark, so their next packets follow the profile. The CLI warns you when it attaches to an already-running app and says the sockets were marked. Restart only if the daemon reports that a descriptor could not be marked.
Does netscope affect the whole system?
No. It scopes selected applications, one profile at a time, up to 32 profiles. Other processes keep their normal networking. There is no system-wide mode.
If you need system-wide filtering or routing, use a firewall, a VPN client with a kill switch, or a network namespace. netscope is not a replacement for those.
Does it hide my IP or encrypt traffic?
No. Filter mode accepts or drops packets. Route mode sends marked packets to an interface you configured; it does not create or encrypt a tunnel. Your VPN or tunnel client provides encryption. netscope does not change your source address by itself.
Does it work with WireGuard, OpenVPN, or another VPN?
Route mode works with any interface that exists on the host, such as wg0 or tun0, as long as the interface can carry the traffic and has a route. netscope does not configure the VPN, its DNS, or its kill switch.
Filter mode is independent of the VPN; it filters destinations regardless of the route they would take.
Does it survive a reboot?
Profiles are stored in /var/lib/netscope/state.json, so definitions survive a reboot. The host rules do not; the daemon recreates them when it starts. Enable netscoped at boot:
sudo systemctl enable netscoped
An application that starts before the daemon is attached late; its existing sockets are marked when it attaches. Start the daemon early if you need the pre-exec path, or restart the app if the daemon reports a mark failure.
Can I edit a profile?
A consumer’s policy can be replaced with netscope policy <profile> --consumer <id> .... The target itself is not edited in place; remove and re-add to change it. enable and disable toggle enforcement without changing the definition.
Can one profile cover several applications?
No. One profile has one target: a native executable, a running process name, a Windows PE run through Wine/Proton, or a Steam app ID. Use separate profiles, up to 32.
Can two profiles target the same application?
Two consumers that name the same target and target uid join one profile and
cgroup through netscope open, so they share the process identity instead of
competing for it. Each consumer has its own policy; a profile carries one route
policy and any number of filter policies. A second netscope add for the same
target and uid is refused with target already scoped; use open when two
programs need the same app.
Does it capture or log my traffic?
No packet capture, no payload logging, no DNS logging. The daemon logs operational events such as profile changes, attachment source, nftables failures, and reconciliation results. netscope logs shows a bounded recent view.
Does it support IPv6?
Filter mode supports IPv6 CIDRs and loopback. Route mode drops IPv6 by default; --allow-ipv6 installs an IPv6 rule and route. If the tunnel does not carry IPv6, leaving it disabled is the safe choice.
How is DNS handled?
netscope does not rewrite DNS. In filter mode, allow the resolver’s address if the profile uses --filter drop. In route mode, the app’s DNS follows the tunnel; configure DNS in the tunnel client or use a resolver reachable through it.
Does it work with Steam games?
Yes, with --steam <appid>. Add the profile before launching the game and launch it through Steam. If Steam was already running, restart it so the game’s process is attached before it opens sockets.
Does it work with Wine, Proton, or Windows games?
Yes, with --wine <path> pointing at the .exe. The path must be absolute, and the file must start with MZ. netscope marks that PE; when Wine opens it, the daemon holds the open, finds the Wine process whose command line names the PE, moves that process into the profile cgroup, and then allows it. status shows pre-open.
Only a Wine process running the selected PE is attached. Another program that opens the same .exe — a file manager, a scanner, a copy — is allowed and left unscoped. wineserver is never attached: it may open the PE on behalf of a loader, but it is a shared server, not the game.
If fanotify cannot be used, the fallback matches Wine processes by command line, including Z:\... paths and the C:\...//drive_c/ form, and attaches late. Sockets opened during startup are marked at attach, so their next packets follow the profile; restart only if the daemon reports a mark failure.
For Proton, select the actual PE path that Wine opens; a copy under steamapps/compatdata/<appid>/pfx/drive_c/ is a different file from the one under steamapps/common/. --steam <appid> remains available for app-id matching. If you do not want to store a path, --process Overwatch.exe holds the Wine loader for a new launch and learns the PE from the loader’s command line; a game that was already running attaches on the next poll, with its existing sockets marked. Flatpak, Snap, and unusual filesystems are not tested: if the profile does not attach, check netscope status and restart the game. Real Wine and Proton launches are not covered by the project’s automated tests; the attach path is exercised with a Wine-named test loader, so report a reproducible failure.
Is it a firewall?
No. It is a per-application scoping tool built on nftables. It does not replace a firewall, and route mode is not a guaranteed kill switch. See Security and Route mode.
Does it work on Windows or macOS?
No. It relies on Linux cgroup v2, fanotify, nftables, and policy routing.
Does it work with Flatpak or Snap applications?
Not tested in v0.2.1. Sandboxed applications may run in a different cgroup or mount namespace, and the executable path you see outside the sandbox may not be the path the daemon matches. Use netscope status to see whether the process was attached, and report a reproducible case.
What license is it under?
Dual-licensed under MIT OR Apache-2.0, at your option. Contributions are accepted under the same terms and require a DCO sign-off.
Why is there no GUI or remote dashboard?
The v0.2.x scope is a CLI and a local root daemon. There is no network listener, no telemetry, and no hosted service. A remote UI would widen the privileged surface and is out of scope for now.