Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. fanotify catches exec and places the process in its profile cgroup before the first socket.
  • Running apps by name. A --process target attaches a process by comm or 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

RequirementCheckNotes
Linux kernel 5.13+uname -r5.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 cgroup2fsHybrid or v1-only systems are unsupported.
nftablesnft --versionUsed for the per-profile table.
iproute2ip -VUsed for policy routing rules and the route-mode blackhole.
systemdsystemctl --versionRuns and supervises netscoped.
zstdzstd --versionOnly 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

ChannelInstallsUnit and configBest for
AUR netscope-binBinariesYes, via the packageArch Linux and CachyOS
Release tarballBinaries, unit, sysusers, config, install scriptsYes, via install.shAny supported distro
Source buildBinaries from target/releaseYes, via install.shDevelopment and ARM builds
cargo install --gitBinaries onlyNoAdvanced 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, and netscope-gui to /usr/bin;
  • installs the netscope-gui desktop 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.conf and creates the netscope group;
  • installs /etc/netscope/config.toml only 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 (or paru -Syu netscope-bin), then sudo systemctl restart netscoped.
  • Tarball or source: unpack or rebuild, then sudo ./install.sh again. The installer stops the old daemon, replaces the binaries and unit, preserves /etc/netscope/config.toml and /var/lib/netscope/state.json, and restarts the service.
  • Cargo: rerun the cargo install command and copy the binaries to /usr/bin, then sudo 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 (or paru -Rns netscope-bin).
  • Tarball or source: sudo ./uninstall.sh from 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

  1. Add a profile. The CLI canonicalizes --exe and sends its path to the daemon. The daemon resolves the path to a device and inode and stores that identity with the profile. A --wine profile does the same for a Windows PE, after checking that the file starts with MZ. A --process profile stores only the process name. A Steam profile stores the app ID instead.
  2. Catch the next exec or open. fanotify watches for exec of 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.
  3. Match on the cgroup or the profile mark. nftables rules use socket cgroupv2 for 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 second socket mark rule matches them. A process cannot escape the cgroup match by renaming itself or by being re-parented.
  4. Filter or mark. A filter profile accepts or drops matching packets. A route profile sets a firewall mark on them.
  5. Route by mark. For route profiles, an ip rule sends 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:

ValueBasePer-profileLimit
Firewall mark0x4e53+ index32 profiles
Routing table20000+ index32 profiles
Rule priority1000+ index × 3232 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_PERM for that inode, the daemon reads the PID, verifies the process still matches, creates the cgroup, writes the thread-group ID to cgroup.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 of FAN_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 wineserver or another Wine helper, the daemon scans for a Wine process whose command line names the PE and attaches that one instead. It never attaches wineserver.
  • 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.
  • status reports the source as pre-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 /proc on the poll interval and reads each process’s comm and command line.
  • A process matches when its comm or 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, so Overwatch, Overwatch.exe, and overwatch.exe select the same process; C:\Games\Overwatch.exe matches 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.
  • status reports the target as name:<name> and the attach source as pre-exec for a loader-held launch or late for 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:

LoopIntervalWork
Pollpoll_interval_ms, default 1000 msScan /proc and attach matching processes that the exec or open hook did not catch.
Refreshrefresh_interval_secs, default 5 sRe-resolve each native or Wine target’s device and inode, move its fanotify mark if the file changed, then attach matching processes.
Health10 s, fixedVerify 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 loopback 127.0.0.0/8 and the --allow-cidr destinations; drop IPv6 except ::1/128 and 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

AreaDecisionConsidered & RejectedEngineering Rationale
Kernel PrimitiveStandard 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 ScopesUnified 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 & LifecycleUnix 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 AttachProcess 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 ScopingProtocol-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 SDKZero-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: fanotify marks the executable inode with FAN_OPEN_EXEC_PERM.
  • Timing: Kernel holds the execve syscall 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: fanotify marks the .exe Windows PE inode with FAN_OPEN_PERM.
  • Timing: Kernel holds the Wine loader’s open of the PE file. The daemon inspects /proc to 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 comm or argv basename.
  • Timing: Attached post-launch.
  • Guarantee: Process is moved into the cgroup for future sockets, and existing open sockets are duplicated via pidfd_getfd and marked with SO_MARK, routing active flows without restarting the app.

4. Security & Privilege Boundaries

  1. Daemon Boundary: netscoped requires CAP_NET_ADMIN and CAP_SYS_ADMIN to configure cgroups, nftables, and fanotify. It runs as a root systemd service (netscoped.service).
  2. Socket Permissions: The control socket /run/netscope/control.sock is created with mode 0660 owned by root:netscope. Unprivileged users must be members of the netscope group to interact with the daemon.
  3. Peer Credential Validation: netscoped checks SO_PEERCRED on every socket connection. A non-root client can only manage profiles matching their own UID (target_uid == peer_uid).
  4. Fallback Safety: If a client crashes, Hold::Peer prevents rule leaks. If the daemon crashes, netscoped.service auto-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:

InputKey
--process Overwatch.exename:overwatch, uid
--wine /games/Overwatch/Overwatch.exename:overwatch, uid
--exe /usr/bin/gamenative:dev:ino, uid
--steam 2357570steam: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:

ConsumerPolicyEffect
exitlinkroute tun0mark packets for the tunnel table
dropshipfilter allow, deny 64.224.26.0/23drop 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:\, or C:\ path; a bare basename still gets the loader hold but no PE hold.
  • filter and route are 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-client crate 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

PropertyValue
Socket typeAF_UNIX, SOCK_STREAM
Default path/run/netscope/control.sock
FramingOne JSON object per line, terminated by \n
EncodingUTF-8
Max request256 KiB (MAX_REQUEST_BYTES)
Max response256 KiB (MAX_RESPONSE_BYTES)
Request timeout10 seconds
Peer identityKernel 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"}
FieldTypeMeaning
idu64Client-chosen request id, echoed in the response. Use a new value per request.
opstringOperation 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}
FieldTypeMeaning
idu64The request id being answered, or 0 for a framing error.
okbooltrue for success, false for an error.
dataanyOperation result on success; null on error.
errorstringHuman-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:

ErrorCause
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 largeThe line exceeded 256 KiB. Answered with id 0.
request timeoutNo complete line arrived within 10 seconds. Answered with id 0.
permission denied: target_uid must match your uidA non-root caller named another user’s UID.
permission denied: profile owned by uid NThe caller is neither root nor the profile owner.
profile <id> not foundUnknown 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:

holdOwnerReleased when
"peer"The client socket connectionThe 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 fileAn 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}
kindFieldsRules
nativepathAbsolute path to an ELF executable. Canonicalized by the daemon.
winepathAbsolute path to a Windows PE that starts with an MZ header.
namenameProcess comm or argv basename. No path separators; at most 64 printable characters. Matched case-insensitively with a trailing .exe optionally stripped.
steamapp_idNon-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"]}
FieldTypeMeaning
default"drop" or "allow"Action for destinations not listed.
allow_cidrsarray of stringsKept reachable with default: "drop".
deny_cidrsarray of stringsDropped 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": []
}
FieldTypeMeaning
interfacestringTunnel interface to route through.
gatewaystring or nullOptional IPv4 next hop.
gateway6string or nullOptional IPv6 next hop; requires allow_ipv6.
linksarray of RouteLinkOrdered 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_lanboolRoute LAN destinations instead of bypassing them. Defaults to false.
allow_ipv6boolCarry IPv6; without it, IPv6 is dropped. Defaults to false.
udp_onlyboolMark only IPv4 UDP; TCP keeps the host’s normal path. Defaults to false.
except_portsarray of u16UDP destination ports that stay direct; requires udp_only. Up to 32.
except_sportsarray of u16UDP source ports that stay direct, pinning sockets that already existed when the route opened; requires udp_only. Up to 64.
exceptarray of stringsDestination 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:

FieldMeaning
idProfile id.
mode"route" when a consumer owns the route, "filter" when only filters exist, "shared" when no policy is attached.
enabledWhether enforcement is active.
targetRendered target: name:<name>, wine:<path>, a native path, or steam:<app_id>.
cgroupAbsolute cgroup path for the profile.
cgroup_levelcgroup v2 depth used by the nftables socket cgroupv2 selector.
pidsAttached process ids, sorted and deduplicated.
nft_tableinet <table> when the profile has policies; empty string otherwise.
markFirewall mark as a hexadecimal string, for example 0x4e53.
tableRouting table id.
rule_priorityip rule priority for the profile’s mark.
attachedpre-exec, pre-open, late, mixed, or none.
healthyDaemon health check result for the profile.
warningsHuman-readable advisories, possibly empty.
consumersConsumer ids holding a claim.
policiesPolicy summaries, one per consumer: <consumer>:filter or <consumer>:route.
generationIncremented when a claim or policy changes; useful for change detection.
linksRoute 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.
statsTraffic 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.

FieldMeaning
tx_bytes / tx_packetsOutbound traffic and packets counted by both selectors (seen_cg + seen_mk), totalled since since_unix.
rx_bytes / rx_packetsInbound 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_bpsSmoothed bits per second (EWMA, α = 0.3) over the last one-second sample.
dropped_bytes / dropped_packetsTraffic matched by a drop rule.
since_unixDaemon 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"}
FieldTypeMeaning
consumerstringLowercase id (^[a-z][a-z0-9-]{0,31}$).
targetTargetSpecProcess identity to scope.
target_uidu32Owner of the target process. A non-root caller must pass its own UID.
namestring or omittedProfile id to use when creating a new scope. Ignored when joining.
holdHold"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:

RequestSuccess 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:

MethodPurpose
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:

ValueBehavior
dropLoopback 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 add still creates a one-shot profile owned by netscope-cli.
  • enable and disable keep the profile and its cgroup but stop or resume enforcement.
  • remove deletes 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

OptionMeaning
--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-lanRoute LAN destinations through the tunnel instead of bypassing them.
--allow-ipv6Carry IPv6 through the profile. Without it, all IPv6 from the profile is dropped.
--udpMark 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:

  1. accepts loopback (127.0.0.0/8, ::1/128);
  2. unless --include-lan, accepts the LAN and link-local ranges listed below;
  3. unless --allow-ipv6, drops all IPv6;
  4. with --udp, accepts the --except-port destinations and the --except-sport sources, 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:

RouteMetricEffect
default dev <iface> [via <gateway>]100Used while the interface has a usable route.
blackhole default1000Used 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:

FamilyBypassed destinations
IPv410.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 224.0.0.0/4
IPv6fc00::/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.

--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.

StateRule
FailureThree consecutive failed probes take a link from up to down.
RecoveryTwo consecutive successful probes take it from down back to up.
WinnerThe first link in --link order whose phase is up.
FailbackA recovered higher-priority link takes over only after its second success, so a flapping link does not bounce the route.
All downNo 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 disable stops enforcement for that profile.
  • netscope remove deletes the nftables table, the policy rule, and the routes. Marked traffic then falls through to the main table.
  • A clean systemctl stop netscoped removes 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

KeyDefaultMeaning
socket_path/run/netscope/control.sockUnix socket for CLI requests. Root-owned, group netscope, mode 0660.
state_path/var/lib/netscope/state.jsonPersistent profile state. Do not edit it by hand.
cgroup_root/sys/fs/cgroup/netscopeParent cgroup for profiles. It must be an absolute path inside /sys/fs/cgroup.
poll_interval_ms1000How often the late-attach scanner looks for matching processes. Bounded to 100–10000 ms.
refresh_interval_secs5How often targets are re-resolved and matching processes are attached. Bounded to 1–300.
max_profiles32Profile ceiling, 1–32. Lowering it prevents new profiles above the new limit.
log_levelinfoerror, warn, info, debug, or trace.
probe_interval_ms2000How often ordered links are probed for failover. Bounded to 500–60000 ms.
probe_target1.1.1.1:443IPv4 address:port used for link probes.
probe_target6[2606:4700:4700::1111]:443IPv6 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 netscoped is 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 --all before 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/8 and ::1/128 destinations, inbound the same source ranges. Local traffic therefore never inflates tx_bytes, rx_bytes, or the TRAFFIC rates.
  • 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 stats becomes null; 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. Lowering max_profiles does 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

OptionMeaning
--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, --helpPrint help.
-V, --versionPrint 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

CodeMeaningTypical message
0Success.—
1Runtime 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>
2Usage 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>
3Daemon unavailable, or cleanup attempted while the daemon is still running.netscope: daemon not running; start with: sudo systemctl start netscoped
4Permission 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.

OptionApplies toMeaning
--exe <path>Native appsExecutable to select. Resolved against the CLI’s current directory, then canonicalized.
--wine <path>Wine/Proton appsWindows PE to select. Must be an absolute path. The daemon canonicalizes it and requires an MZ header.
--process <name>Running appsProcess name or argv basename. No install path. Matches comm and command-line basenames case-insensitively; a trailing .exe is optional.
--steam <appid>Steam appsNumeric Steam app ID. Matches the app and its children.
--filter <drop|allow>Filter profilesDefault action.
--allow-cidr <cidr>--filter dropDestination to keep reachable. Repeatable.
--deny-cidr <cidr>--filter allowDestination to drop. Repeatable; at least one is required.
--route <iface>Route profilesTunnel interface.
--link <spec>Route profilesOrdered 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>--routeIPv4 next hop. Legacy single-link form of --link <iface>@<ipv4>.
--gateway6 <ipv6>--route --allow-ipv6IPv6 next hop. Legacy single-link form of --link <iface>#<ipv6>.
--include-lan--routeRoute LAN destinations instead of bypassing them.
--allow-ipv6--routeCarry IPv6; without it, IPv6 is dropped.
--udp--routeMark only IPv4 UDP; TCP keeps its normal route.
--except-port <port>--route --udpUDP destination port that stays direct. Repeatable, up to 32.
--except-sport <port>--route --udpUDP source port pinned direct. Repeatable, up to 64.
--except <cidr>--routeDestination 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.
  • --wine takes an absolute path to a regular file that starts with MZ. The daemon canonicalizes the path and stores its device and inode.
  • --process takes a name, not a path. It is matched against the process comm and its executable basename, and against any argv basename of a Wine loader; Overwatch, Overwatch.exe, and overwatch.exe are 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-cidr is rejected with --filter allow.
  • --deny-cidr is rejected with --filter drop, and --filter allow needs at least one.
  • --link conflicts 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.
  • --gateway is rejected outside --route.
  • --gateway6 is rejected without --allow-ipv6.
  • --include-lan, --allow-ipv6, --udp, --except-port, --except-sport, and --except are accepted with either --route or --link.
  • --except-port requires --udp and takes 32 ports; port 0 is rejected.
  • --except-sport requires --udp and takes 64 ports; port 0 is 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.
  • --except takes up to 30 CIDRs. The daemon installs each as an ip rule to main at 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

CommandExitWhy
netscope status0Daemon reachable.
netscope status with the service stopped3No socket.
netscope add bad_name --exe /bin/true --filter drop2Uppercase and underscore are invalid in a profile ID.
netscope add x --exe /bin/true --filter allow2allow needs at least one --deny-cidr.
netscope add x --exe /nope --filter drop2Path cannot be resolved.
netscope add x --exe /bin/true --route tun91The daemon cannot install a route through a missing interface.
netscope add x --exe /bin/true --filter drop as a non-group user4Socket permission denied.
sudo netscope cleanup --all while netscoped runs3Cleanup 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:

ComponentPrivilegeExposure
netscopedrootUnix socket, fanotify, child nft/ip processes
netscope CLIuserUnix 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 netscope group can add and manage profiles owned by their own UID.
  • Root can manage any profile and can pass --uid to create one for another user. --uid is 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 --uid path.

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: --wine takes an absolute path. The daemon canonicalizes it, requires a regular file, and requires the first two bytes to be MZ before recording its device and inode. No ELF check is applied.
  • Process names: --process takes a name, not a path; it is rejected if empty, longer than 64 printable characters, or contains a path separator. The daemon refuses netscope, netscoped, and the shared wineserver as 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 netscope group small. Membership is a privilege.
  • Keep /etc/netscope/config.toml and /var/lib/netscope/state.json root-owned and not writable by group members.
  • Do not expose /run/netscope/control.sock through 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=no and ProtectKernelModules=no are deliberate: the daemon must write the cgroup hierarchy, and nft_socket may autoload on the first cgroup rule.
  • Review the unit’s sandboxing before changing it. It grants CAP_SYS_ADMIN, CAP_NET_ADMIN, and CAP_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 need CAP_NET_ADMIN. If a descriptor cannot be reached, the daemon logs mark profile=... error=... and that socket keeps its original route. A --process target 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_ADMIN can 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 --process target matches every process with that comm or argv basename, including another user’s process if it runs under the same UID. Both are attached. Use --exe or --wine when the identity must be exact.
  • Non-Wine processes that open a selected PE. A --wine profile 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. wineserver is 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

SymptomCheckLikely cause and fix
daemon not running (exit 3)systemctl status netscopedStart it: sudo systemctl start netscoped. If it exits immediately, read journalctl -u netscoped -n 50.
permission denied (exit 4)id -nG includes netscopeAdd 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 kerneluname -rKernel older than 5.13 cannot hold an exec. Upgrade the kernel.
doctor fails on cgroupsstat -fc %T /sys/fs/cgroupNot cgroup2fs means a v1 or hybrid hierarchy. Boot with unified cgroup v2.
nft: command not foundcommand -v nftInstall nftables.
ip: command not foundcommand -v ipInstall iproute2.
Operation not permitted from nftables or cgroupssystemctl cat netscopedThe daemon is not running as root or lost a required privilege. Run the shipped unit; do not drop capabilities it needs.
Config change is ignoredjournalctl -u netscoped -n 50The daemon reads the file at startup. Restart it. An unknown key is rejected.
add says the profile existsnetscope listNames are unique. Remove the old profile or choose another name.
add fails at the profile limitnetscope listAt most 32 profiles. Remove unused ones.
App is not scopednetscope status, then cat /proc/<pid>/cgroupThe 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 worksnetscope status shows lateExisting 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 scopednetscope status after launching the gameAdd the profile before launching, and launch the game through Steam. Restart Steam if it was already running.
Wine/Proton game is not scopednetscope status shows pre-open, mixed, late, or nonePoint --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 appnetscope 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 scopednetscope status shows name:<name>, cat /proc/<pid>/commThe 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 connectivityip 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 dropsThis is intendedThe blackhole rejects traffic while the interface is down. Restore the tunnel or remove the profile.
LAN devices unreachable in route modeLook for --include-lanBy 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 workingnetscope statusRoute mode drops IPv6 unless --allow-ipv6 is set. Add it only if the tunnel carries IPv6.
DNS fails in filter modeThe profile’s --allow-cidr listAllow 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 modeTunnel DNS configurationnetscope does not configure DNS. Push a resolver through the tunnel or set one manually.
Stale tables or rules after a crashsudo nft list tables, ip rule showStop the daemon, then run sudo netscope cleanup --all. See Cleanup.
cleanup exits 3systemctl is-active netscopedCleanup 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.toml with Configuration.
  • cgroup root outside the cgroup2 mount. cgroup_root must 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 --all before starting.
  • Socket path not writable. /run/netscope must exist and be writable by root. The shipped unit creates it.

The app is not scoped

Check in this order:

  1. netscope list confirms the profile exists and is enabled.
  2. netscope status shows a process in PIDS while the app runs.
  3. cat /proc/<pid>/cgroup contains /netscope/<profile>.
  4. 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/app and 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. doctor reports the attach source; late attachment is reported as late.

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 status should show wine:/path/to/game.exe. A file under steamapps/common/ and its copy under steamapps/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 containing wine-preloader. wineserver is 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, status shows late; 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, status reports fanotify unavailable; poll-only, and the poll fallback attaches late and marks existing sockets. It also reports executable missing when 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 tun0 route means the interface is down or the gateways are wrong. If the tunnel needs a next hop, pass --gateway or --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

OptionEffect
--output <path>Write the archive here instead of /tmp/netscope-support-<UTC>.tar.zst.
--include-logsInclude logs.txt with recent netscoped journal lines. Omitted by default.
--include-configInclude config.toml. Omitted by default.
--no-redactDisable redaction. Refused unless --acknowledge is also given.
--acknowledgeConfirm 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.

MemberContents
README.txtReview-before-upload warning and a statement that the bundle is never uploaded automatically.
manifest.jsonMachine-readable manifest: generated_by, generated_at, files, redaction settings and labels, and excluded categories.
version.txtnetscope and netscoped versions, API version, and build identity.
doctor.txtThe human-readable netscope doctor report.
doctor.jsonThe same report as JSON.
status.jsonDaemon status and profile status, including attach source and PIDs. Present when the daemon is reachable.
list.jsonThe daemon’s profile list response. Present when the daemon is reachable.
system.txtKernel, distribution, architecture, cgroup mode, and tool versions.
service.txtsystemctl status netscoped and the unit’s effective state.
logs.txtRecent journal lines for netscoped. Present only with --include-logs.
nft.txtThe netscope_* nftables tables only, not your full ruleset.
ip.txtip rule and the netscope route tables.
cgroups.txtThe netscope cgroup hierarchy and profile membership.
config.tomlThe daemon configuration. Present only with --include-config.
state.jsonThe 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.txtPresent only if a collection step failed or reported a warning; lists which step and why.

What is deliberately excluded

The bundle does not include:

  • /proc contents, 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:

ValueReplaced 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, and errors.txt for what was collected or failed;
  • state.json and config.toml for paths or names you do not want public;
  • logs.txt for unrelated messages from other services;
  • ip.txt and nft.txt for 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;
  • /tmp is 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_FANOTIFY and CONFIG_FANOTIFY_ACCESS_PERMISSIONS are required for pre-exec attachment.
  • CONFIG_NFT_SOCKET is required for the socket cgroupv2 match.

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/src when 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_PRELOAD shim, 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

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.