This file includes everything needed to build, configure, and run nullnet.
For what nullnet is and why, see the README; for how the pieces fit together, see docs/architecture.md.
This repository is a Cargo workspace holding the three binaries that make up the architecture plus the shared gRPC interface.
.
├── members/
│ ├── nullnet-client/ # runs on each host, exposes local services to the control plane
│ ├── nullnet-server/ # control plane: orchestrates VLAN/VXLAN setup and tears them down
│ ├── nullnet-proxy/ # ingress proxy: maps `service_name:80` requests to the right host
│ └── nullnet-grpc-lib/ # shared gRPC interface + generated types (proto + build.rs live here)
├── ebpf/ # eBPF program loaded by nullnet-client (nightly toolchain)
└── xtask/ # builds the eBPF program + the client userspace
- install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
The repository should be cloned under /root so the provided setup-*.sh scripts and
.service units work without changes.
Run the setup scripts as root. The supplied systemd services also run as root; privileged commands execute directly without sudo.
-
set environment variables (in
members/nullnet-server/.env)NET_TYPE=VXLAN CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> JWT_SIGNING_KEY=<32 raw bytes or 64 hex chars> MFA_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> DATABASE_URL=/var/nullnet/data/nullnet.db # SQLite db path; default shown ADMIN_BOOTSTRAP_USERNAME=admin # only used the first time the users table is empty ADMIN_BOOTSTRAP_PASSWORD=<a real password> # ditto — defaults to admin/admin if either is unset PROXY_IP=192.168.1.100 ENCRYPTION_ENABLED=true INGRESS_ALLOW_TCP_PORTS=22,8080 # inbound TCP listeners every node accepts INGRESS_ALLOW_UDP_PORTS= # inbound UDP listeners (e.g. Swarm gossip 7946) EGRESS_ALLOW_TCP_PORTS= # outbound TCP dsts (e.g. 80,443 for updates) EGRESS_ALLOW_UDP_PORTS=53,123 # outbound UDP dsts (DNS, NTP)CERT_ENCRYPTION_KEYis required — the server refuses to start without it. It encrypts TLS certificate private keys (and the DNS-provider credentials of ACME-issued certs) at rest; keep it stable, since rotating it makes existing encrypted data undecryptable. Generate one withopenssl rand -hex 32.JWT_SIGNING_KEYandMFA_ENCRYPTION_KEYare also required — the server refuses to start without them.JWT_SIGNING_KEYsigns the admin UI's session access tokens;MFA_ENCRYPTION_KEYencrypts TOTP secrets at rest. Each is deliberately a separate key fromCERT_ENCRYPTION_KEY(no shared blast radius between secret classes) — generate each independently withopenssl rand -hex 32and keep them stable, for the same reason asCERT_ENCRYPTION_KEY.DATABASE_URLis the path to the server's SQLite database (users, sessions, service config, etc.), created on first start along with any parent directories. Defaults to/var/nullnet/data/nullnet.dbif unset.ADMIN_BOOTSTRAP_USERNAME/ADMIN_BOOTSTRAP_PASSWORDcreate the first admin account the one time theuserstable is empty — irrelevant on every subsequent start. If either is unset, the server falls back toadmin/adminand prints a loud warning; change that password immediately after first login if the admin UI is reachable by anyone else.PROXY_IPis the IP of the host runningnullnet-proxy(the egress gateway). It is required to enable egress brokering: when a registered service reaches out to the internet the server builds a per-initiator egress edge to this host. If unset, egress is disabled (the trigger is rejected with "PROXY_IP is not configured") — ingress still works.ENCRYPTION_ENABLEDtoggles per-tunnel VLAN/VXLAN encryption (AES-256-GCM for VLAN, XFRM/MACsec for VXLAN) and defaults totrue— omit it to keep encryption on. Set it tofalse/0/noto run tunnels unencrypted instead (a bare vxlan/veth link, no XFRM SA/policy or MACsec).The four
{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTSlists are the global host-NIC firewall allowlist — decided here once (single point of decision) and delivered to every node in itsNetworkTyperesponse at startup. They apply uniformly to all clients (matched on destination port). Put22inINGRESS_ALLOW_TCP_PORTSor every strict node loses SSH the moment its client starts. A node that needs name resolution / time sync needsEGRESS_ALLOW_UDP_PORTS=53,123; DHCP renewal needs67(and inbound68for broadcast replies). The gateway node (the one whose IP equalsPROXY_IP) is switched to gateway posture automatically — all outbound allowed and tracked — so no per-node flag is needed; inbound there still obeys these lists, so include80,443. -
the admin web UI now requires login (JWT-based, cookie sessions). Roles are
admin(all permissions) anduser(explicit per-resource scopes, assigned from the Users page). MFA (TOTP) is optional per-account — an account without it configured sees a persistent banner prompting setup (QR code + confirm step), until it's done. SeeADMIN_BOOTSTRAP_USERNAMEabove for how the first admin account is created. -
Set optional server env
UI_TLS_DOMAIN=control.example.comto serve the UI onhttps://control.example.com:8080with its matching managed certificate (exact name preferred, then a single-label wildcard). Point that hostname at the server. Until a usable certificate is installed, the UI uses a self-signed setup certificate and reports this in Events. For first setup, usehttps://<server-ip>:8080and accept the certificate warning; an existing HSTS policy can block this on the hostname. Install the certificate from the Certificates page, then use the hostname: the UI switches automatically and reloads renewals without a restart. Failed replacements or removal retain the last loaded certificate in memory and report an event; it still expires normally. After a restart without a usable stored certificate, setup mode returns. LeavingUI_TLS_DOMAINunset preserves self-signed UI TLS. -
TLS certificates are issued from Let's Encrypt via a DNS-01 challenge (UI: Certificates page). Each cert stores its DNS-provider credentials encrypted at rest and is renewed automatically before expiry. The renewal scan is tunable via optional env vars (defaults shown):
CERT_RENEWAL_CHECK_INTERVAL_SECS=43200 # how often to scan (12h) CERT_RENEWAL_DAYS_BEFORE=30 # renew when expiring within N days CERT_RENEWAL_DNS_PROPAGATION_SECS=30 # wait after writing the TXT record -
events (UI: Events page) are persisted to the SQLite database rather than kept in a bounded in-memory buffer, so a burst of routine events can no longer evict a warning/error before anyone sees it. They're pruned automatically past a retention window, tunable via optional env vars (defaults shown):
EVENT_RETENTION_DAYS=7 # how long an event is kept EVENT_RETENTION_SWEEP_INTERVAL_SECS=3600 # how often the deletion sweep runs (1h)Writes use a bounded queue and batched transactions; normal events appear after commit. Storage failures retain accepted events and retry, reporting failure/recovery in Events and stderr. A full queue drops new events instead of blocking routing; Events reports the dropped count when storage catches up. Shutdown drains for up to five seconds; a longer outage, forced kill or power loss can lose events still queued in memory.
-
sessions (UI: Sessions page) are persisted the same way: every ingress session and every external destination reached over an egress edge is stored, so the page shows the full history and not just what is active right now. Filter it by status (active/ended), service, direction (ingress/egress), and egress policy verdict (allowed/blocked). Ended sessions are pruned on the same sweep as events:
SESSION_RETENTION_DAYS=30 # how long an ended session is keptAn active session is never pruned, however old. Sessions still marked active are closed at startup, since the state they described died with the previous process. History writes run independently of networking, in ordered batches. Database failures retain accepted updates for retry; Events reports failures and recovery. If the bounded buffer fills, new history updates are dropped until the accepted prefix drains, and affected open rows become history interrupted (their actual end time is unknown). Normal shutdown drains for up to five seconds; forced termination can lose queued history.
-
Upgrade server, proxy and clients together: VXLAN backend/egress packet release uses an acknowledged
NetReadycommand after both endpoints finish setup. Nullnet reserves Linux interface groups0x4e000000–0x4e400000for tunnel cleanup; do not assign unrelated interfaces to that range. Failed setup is not acknowledged as ready. Failed teardown keeps its ID reserved; after correcting the reported error, restart that client to purge stale state and release the reservation. Application containers do not need restarting. -
the gRPC control channel itself (nullnet-client/nullnet-proxy ↔ nullnet-server) is TLS-only, authenticated by a private CA. On first boot the server generates its own CA (
members/nullnet-server/grpc-tls/ca-cert.pem+ca-key.pem, created once and never regenerated) and signs its leaf cert with it. Copyca-cert.pemto every client/proxy host and setCONTROL_SERVICE_CA_CERTthere (see below) if it isn't at the default path (the repo root, i.e.nullnet/ca-cert.pem) — clients pin the channel to that CA and do full standard chain validation, so only a leaf actually signed by it is accepted. Because clients trust the stable CA root rather than the leaf, rotating the leaf later needs no client-side changes. Client authentication (mTLS) remains a further follow-up.Validation includes hostname matching, so the leaf's SAN must cover whatever host/IP clients use as
CONTROL_SERVICE_ADDR. It's derived in this order:CONTROL_SERVICE_TLS_SAN(comma-separated, if set) → the server's ownCONTROL_SERVICE_ADDR(if set — often already the same address clients are told to connect to) →localhost. SetCONTROL_SERVICE_TLS_SANexplicitly if the server's address isn't in its own.envor differs from what clients use. -
service configuration is split per stack and lives in the server's SQLite database as normalized rows (
stacks/services/service_triggers/service_dependencies/routes— one service's triggers/dependency branches are child rows of its own row, keyed by an autoincrement id). Edit it through the admin UI's Config page (per-service widgets), or directly viaGET/POST/DELETE /api/service-config/{stack}(services, structured JSON) andGET/POST /api/routes/{stack}(routes) — both validate and apply live, no restart needed. The Config page's Export/Import buttons (GET/POST /api/service-config/{stack}/export/import) round-trip a stack as a single TOML file — a hand-editable backup/version-history format outside the DB, validated through the exact same path as the widget UI on import.Upgrading a host still on pre-
v0.2file-based config? There is no automatic migration —./services/<stack>.tomlfiles are no longer read at all, by anything, once this build starts. For each stack, create it (Config page → name it → "Create stack") and use Import from TOML to paste/upload that stack's existing.tomlfile before relying on this build in production, or the stack comes up with zero services/routes until you do.The TOML shown below is that Export/Import format, and the clearest way to document the field set, which the JSON wire format (and the widget UI) mirrors field-for-field. For example, this defines a stack called
my-app:[[services]] # http entry point, backed by a Docker container name = "color.com" timeout = 0 docker_container = "my-app_color" port = 3001 proxy_dependencies = [["fs.color.com"]] backends = ["ts.color.com"] [[services]] # backend-only dep of color.com name = "fs.color.com" docker_container = "my-app_fs" port = 8080 [[services]] # backend target — its port is used by the trigger name = "ts.color.com" docker_container = "my-app_ts" port = 5555 [[services]] # host (non-Docker) service, matched by process name = "metrics.com" timeout = 0 process_path = "/usr/local/bin/metrics-exporter" port = 9090 [[services]] # raw tcp — proxy binds listen_port and forwards name = "redis.internal" timeout = 0 docker_container = "my-app_redis" port = 6379 protocol = "tcp" listen_port = 6379 [[services]] # traffic filters (egress + ingress) name = "api.internal" timeout = 0 docker_container = "my-app_api" port = 8000 egress_filter = { block = { groups = [ [{ field = "country", condition = "equal", values = ["RU", "CN"] }], [{ field = "dst_ip", condition = "contains", values = ["203.0.113.0/24"] }], ] } } ingress_filter = { allow = { groups = [ [{ field = "country", condition = "equal", values = ["US", "IT"] }], ] } } -
a service is hostable when it declares a match key plus a
port(the backend port replicas are reached on). Clients hold no service file: each reports its raw local observations and the server matches them against these keys. A container/process may match several services across stacks; every match registers a replica:docker_container— the Swarm service label (com.docker.swarm.service.name) or, standalone, the container name; matched against a running containerprocess_path— a listening process's exe path (/proc/<pid>/exe); matched against a host (non-Docker) service
-
host_ipoptionally limits either match to one node's control-channel IPv4 address (for example,host_ip = "192.168.1.103"for that host's SSH service). Omit it to match all hosts. -
pausable = trueopts a Docker service into pausing when idle (the Config checkbox). It defaults tofalse, including existing DB rows. Backend services follow the same setting. Live chains and egress sessions keep containers running; disabling pause starts an asynchronous resume of a paused replica. If several declarations share a container, all must opt in. Paused initiators cannot start work themselves; use this only when incoming traffic can wake them. -
timeoutcontrols proxy-reachability: when present the service is a proxy-reachable entry point with that per-client idle timeout in seconds (0disables the timeout); omit it to keep the service off the proxy (backend-only) -
max_networksis an optional hard cap on dedicated ingress networks for a proxy-reachable service. Independently of that setting, encrypted VXLAN ingress switches new clients to multiplexing when the proxy's RPC backlog and slot-wait time grow for consecutive windows. A host-scoped controller first pauses dedicated setups, then additively admits one more probe per healthy window; renewed congestion immediately returns the rate to zero. Stable queues do not activate it, and only sustained successful probes under traffic remove the limit (idle time does not count as recovery). Multiplexed clients get one policy generation per client IP over a shared VXLAN. Cross-host groups share a persistent host-pair IPsec transport; packet metadata and endpoint maps preserve per-client isolation. Same-host multiplexing uses the same isolation policy with direct eBPF redirection and no VXLAN hairpin. -
proxy_dependenciesis a list of independent dep chains walked when the service is reached via aProxyRPC from nullnet-proxy; each inner array is one linear branch and all branches are brought up in parallel -
backends = ["peer-a", "peer-b"]lists services this service may trigger. Each peer must declare a nonzeroport; that port identifies its trigger. One source cannot target different peers on the same port. A trigger opens only the direct connection, without following the peer's dependencies. -
service names must be globally unique across every stack, and dependency chains stay intra-stack.
-
protocolselects how a proxy-reachable service is exposed:http(the default — routed byHostheader on the shared 80/443 listeners) ortcp/udp, which each requirelisten_port— the external port nullnet-proxy binds directly and forwards raw traffic from.listen_portmust be globally unique per protocol across every stack (the server refuses to start, or rejects a hot-reload, if two services claim the sameprotocol/listen_portpair) -
TCP/UDP
listen_portvalues are automatically allowed through the proxy host's eBPF ingress firewall at startup and refreshed within the client's 10-second service-report interval. Backendportvalues are not opened; explicit firewall allowlists still apply. -
egress_filter/ingress_filterrestrict traffic with an AND/OR combination of conditions, evaluated viarpn-predicate-interpreter(the same postfix-expression engineappguard-server/src/firewall/uses).groupsis OR-of-ANDs: every condition within a group must match (AND), and groups are OR'ed together.blockdenies a match (no match → allow);allowpermits only a match (no match → deny) — same either way if the filter is omitted (no policy). Matchable fields:country/org(condition=equal/not_equal,values= ISO alpha-2 codes / ASN organization names, matched case-insensitively — resolved server-side, from one shared geo cache; the org is what the topology and Sessions views display), andsrc_ip/dst_ip(condition=contains/not_contains,values= CIDRs/addresses the peer IP is checked against).src_iponly applies to ingress filters,dst_iponly to egress — the server rejects the other. Two directions:- egress — where a service may reach on the internet (destination country/org/IP). Enforced at
the initiator's nullnet-client: the first packet of each new external flow is held and verdicted,
denied destinations show a
BLOCKEDchip in the topology UI, and editing the filter at runtime tears down already-established flows the new filter forbids. - ingress — which external clients may reach a proxy-reachable service (client source
country/org/IP). Enforced server-side at the nullnet-proxy chokepoint: HTTP denials get a
403, raw tcp/udp denials close the connection. Only valid on a service with atimeout(an entry point) — the server rejects an ingress filter on a backend-only service.
- egress — where a service may reach on the internet (destination country/org/IP). Enforced at
the initiator's nullnet-client: the first packet of each new external flow is held and verdicted,
denied destinations show a
-
[[route]]blocks add NGINX-location-style HTTP dispatch on top of[[services]], matching byhost+ prefixpath(defaults to/) rather than byHostheader alone:[[route]] # forward to a backend, stripping the matched prefix host = "ops.example.com" path = "/api" service = "api.internal" # must be an in-stack, http-protocol, proxy-reachable service strip_prefix = true # "api" sees "/users", not "/api/users" [[route]] # redirect, no backend needed host = "old.example.com" path = "/old" redirect_to = "/new" redirect_status = 301 # optional, defaults to 301; one of 301/302/307/308 preserve_path = true # /old/x?foo=bar -> /new/x?foo=bar preserve_query = trueserviceandredirect_toare mutually exclusive (exactly one required per route);strip_prefixonly applies toserviceroutes,preserve_path/preserve_queryonly toredirect_toroutes — each is rejected on the wrong kind. All four flags default tofalse, so a stack with no[[route]]blocks keeps today's plain per-serviceHost-header routing. A given(host, path)pair must be claimed by exactly one route across every stack — a collision (even within the same stack) is a hard config error, reported as aroute_conflictevent. Configurable from the admin UI's Routes page as well as by editing the TOML directly. -
run the project as a daemon (from the repo root)
./setup-server.sh -
the server regularly renders one Graphviz file per stack under
members/nullnet-server/graphs/<stack>.dot
-
set environment variables (in
members/nullnet-proxy/.env; setCONTROL_SERVICE_ADDRto the IP ofnullnet-server)CONTROL_SERVICE_ADDR=192.168.1.100 CONTROL_SERVICE_PORT=50051 CONTROL_SERVICE_CA_CERT=../../ca-cert.pem # optional, defaults to the repo root's ca-cert.pem HSTS_ENABLED=true # optional, defaults to true; false for developmentCONTROL_SERVICE_CA_CERTpoints at a copy of the server's owngrpc-tls/ca-cert.pem(see the server section above), used to pin and authenticate the control channel. If unset it defaults to../../ca-cert.pem— the repo root'sca-cert.pem, relative to the service'smembers/nullnet-<component>working directory; either way, startup fails if the file is missing or isn't a valid cert. -
run the project as a daemon (from the repo root)
./setup-proxy.sh -
the proxy listens on port 80 (requests in the form
service_name:80) and, for hosts that have a TLS certificate, on port 443 — HTTP requests to those hosts get a 301 redirect to HTTPS -
HTTPS responses use
Strict-Transport-Security: max-age=31536000, withoutincludeSubDomainsorpreload.HSTS_ENABLED=falsesuppresses the header, including backend-supplied policies; restart the proxy after changing it. Disabling does not clear policies already cached by browsers. Use separate development hostnames: HSTS covers every port on a hostname, including a self-signed admin UI on:8080, and prevents bypassing certificate errors. -
for services declared with
protocol = "tcp"or"udp"in the server's stack config, the proxy also opens a raw listener on eachlisten_portand forwards traffic to the matching service — noHostheader involved. This table is pushed live by the server, so listeners open and close asservices/<stack>.tomlchanges, without a proxy restart
-
set environment variables (in
members/nullnet-client/.env; setCONTROL_SERVICE_ADDRto the IP ofnullnet-server). The uplink interface is auto-detected from the host's default route.CONTROL_SERVICE_ADDR=192.168.1.100 CONTROL_SERVICE_PORT=50051 CONTROL_SERVICE_CA_CERT=../../ca-cert.pem # optional, defaults to the repo root's ca-cert.pemCONTROL_SERVICE_CA_CERTpoints at a copy of the server's owngrpc-tls/ca-cert.pem(see the server section above), used to pin and authenticate the control channel. If unset it defaults to../../ca-cert.pem— the repo root'sca-cert.pem, relative to the service'smembers/nullnet-<component>working directory; either way, startup fails if the file is missing or isn't a valid cert.⚠️ The client attaches a default-deny eBPF firewall to the uplink NIC on startup. It permits only the nullnet control plane (gRPC to the server), data plane (VXLAN to peers), established returns, ICMP (always, both directions — echo + PMTUD), and the port allowlist. That allowlist is no longer set here — it is decided globally onnullnet-server(the four{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTSvariables) and delivered to the client in itsNetworkTyperesponse at startup, so there is a single point of decision. Make sure22is in the server'sINGRESS_ALLOW_TCP_PORTSbefore starting a client over SSH, or the session dies. The gateway-vs-strict posture is likewise derived server-side fromPROXY_IP— no per-client flag. -
run the project as a daemon (from the repo root)
./setup-client.shSetup applies and persists a conntrack limit of at least 1,048,576 entries, preserving a higher existing limit. No manual sysctl configuration is needed. It also keeps NetworkManager and udev hotplug helpers from managing Nullnet-owned interfaces, avoiding competing network-configuration work during tunnel bursts.
The client also filters owned-device kernel uevents and link/address/route/neighbor
notifications before delivery to udev, NetworkManager, OVS, Avahi and the desktop
portal. Docker/foreign devices, netlink replies and dumps pass. This early socket
filtering is separate from udev rules, which run after event queueing. Root needs
CAP_SYS_PTRACE to duplicate consumer sockets, alongside its existing BPF privileges.
The client mounts bpffs if needed and pins its filters under
/sys/fs/bpf/nullnet-device-events-v1. It reconciles new consumer sockets every
second; existing foreign filters are preserved and reduced coverage appears in
Events. A three-second lease makes filters pass all notifications after client
exit; pinned filters resume on client restart. Inactive filters remain attached
until their consumer closes its socket, so shutdown never removes a replacement
filter installed by that consumer. Set NULLNET_DEVICE_EVENT_BYPASS=false in the
client .env to disable filtering. Other subscribers and namespaces are untouched.
On upgrades, deploy the server, proxy and all clients from the same revision.
Client startup cleans stale Nullnet network state while preserving Docker interfaces;
routine container restarts are unnecessary. Containers already damaged by an older
client's cleanup need a one-time restart (Swarm: docker service update --force SERVICE).
Open Observation below Config and confirm the checkbox to enable all directed backend links within the selected stack. Activation lists any port conflicts; every destination must have a declared nonzero port. Proxy dependencies stay unchanged. The saved configuration is read-only until observation stops.
The service-only matrix counts backend sessions since activation (including ones
already open). Gray borders mark saved backend links. Counts persist across
restarts and session retention; SESSION_RETENTION_DAYS still controls session
details. Stop observation to restore the saved config and review added/unused
backends. Applying updates only backend lists. Completed observations remain
available; results cannot overwrite a configuration edited since activation.
VXLAN edges create fresh dedicated interfaces and use fixed TC redirects;
Nullnet shares UDP 47890, separate from Docker Swarm’s UDP 4789. Egress policy rules use priorities 1000–1015 and protocol 242; reserve these for Nullnet.
Reserve TC blocks 7801–7832 and interface names nn-tc-0 through nn-tc-15
for Nullnet's shared classifiers. These down dummy interfaces carry no traffic;
each edge still has dedicated interfaces and encryption keys. Startup removes
stale holders using their reserved name, group, and device type before rebuilding the classifiers.
br_<id>_<side> is the gateway interface name, not a Linux bridge. Startup
keeps routed overlay traffic allowed by owned interface groups while preserving
the host's existing FORWARD policy. Pooling and policy multiplexing are deferred.
The client and proxy services set LimitNOFILE=524288: fresh endpoints retain netlink sockets, and each proxied connection needs downstream and upstream sockets. Update both installed units and run systemctl daemon-reload when upgrading.