Files
netbird/client/link
Maycon Santos 66a28fd666 [client] Add nblink, an unprivileged forwarder into the network (#7955)
Reaching an internal service meant installing the agent, which needs root
for a TUN device. nblink runs the embedded client in netstack mode and
forwards local HTTP listeners to upstreams inside the tunnel, so it works on
managed machines and in rootless containers. Upstream names resolve
in-process.

Forwards bind loopback unless --allow-public-bind is given. The listener
refuses rebound and cross-site browser requests and bounds stalled request
bodies. It ships as release archives and a distroless image that runs under
an arbitrary UID.

An embedded client given no StatePath now keeps its state in memory rather
than falling back to the installed agent's state file, which every embedded
client in a proxy process also shared. The state manager gets an explicit
in-memory store for it.
2026-10-12 13:54:15 +02:00
..

nblink

nblink forwards local ports into a NetBird network from an unprivileged process. It runs the client in userspace mode, so there is no TUN device to create and no elevated privileges to grant. That lets it run on managed machines and in rootless containers where the normal agent cannot.

Overlay names on the upstream side are resolved inside the tunnel, so /etc/resolv.conf is never touched.

Install

Download the archive for your platform, extract it, and run the binary. There is no installer and no service to register.

Use

A forward is one self-describing argument, and the flag repeats:

nblink --forward 'http://8080=https://grafana.internal'

The scheme on the left selects the listener type. The right side is the upstream reached over the network. Giving only a port binds loopback, so the example above listens on 127.0.0.1:8080.

Without a setup key, nblink opens a browser to log in and prints the URL so it also works over SSH.

# several forwards from one session
nblink --forward 'http://8080=https://grafana.internal' \
       --forward 'http://9090=https://prometheus.internal'

# non-interactive
nblink --forward 'http://8080=https://grafana.internal' --setup-key "$NB_SETUP_KEY"

# check the configuration without touching the network
nblink --forward 'http://8080=https://grafana.internal' --check

Port 0 asks the operating system for a free port. The address actually bound is printed at startup.

Container

Every flag has a matching NB_-prefixed environment variable, which is how the image is configured. NB_FORWARD takes a comma separated list.

docker run --rm -p 127.0.0.1:8080:8080 \
  --mount type=bind,src="$(pwd)/nb_setup_key",dst=/run/secrets/nb_setup_key,readonly \
  -e NB_SETUP_KEY=file:/run/secrets/nb_setup_key \
  -e NB_FORWARD='http://0.0.0.0:8080=https://grafana.internal' \
  -e NB_ALLOW_PUBLIC_BIND=true \
  netbirdio/nblink

The key file has to exist before the run and be readable by the container user, which is UID 65532 in group 0. A bind mount keeps the host file's owner and mode, so a root-owned key written with the usual chmod 600 is not readable and the run fails on startup with permission denied. Either give the file to that UID (chown 65532) or keep it mode 640 in group 0 (chgrp 0). If the source path does not exist, --mount refuses to start the container; the older -v form creates a directory there instead, which the read then reports as a directory.

The image needs no --privileged, no --cap-add NET_ADMIN, no --device /dev/net/tun and no host networking.

Inside a container the listener has to bind 0.0.0.0, because a published port arrives on the container's own interface rather than its loopback. That still requires NB_ALLOW_PUBLIC_BIND, so exposing a forward is always something the operator wrote down. Publish it on the host's loopback as above: a bare -p 8080:8080 publishes on every host interface, which hands the peer's identity to anyone on the network who can reach the port.

A public listener accepts requests addressed to localhost or to an IP address. Any name other machines use to reach it, such as a DNS name or a Kubernetes Service name, has to be listed with --allowed-host (NB_ALLOWED_HOST). See Limits for why.

The image keeps its state in memory, so it runs on a read-only root filesystem and each start registers a new peer. To keep one identity across restarts, set NB_STATE_DIR=/var/lib/nblink and mount a volume there:

docker run --rm --read-only -p 127.0.0.1:8080:8080 \
  -v nblink-state:/var/lib/nblink -e NB_STATE_DIR=/var/lib/nblink \
  ...

Without a state directory, use an ephemeral setup key so the peers left behind by earlier starts are removed once they go offline, rather than piling up.

A kept identity is not yet a kept login. Without a setup key every start runs the browser flow, even when the peer's session is still valid, because the token is resolved before the stored profile is opened. Give a restart that has to be unattended a setup key.

Kubernetes and OpenShift

The image fits OpenShift's restricted-v2 SCC and the Kubernetes restricted Pod Security Standard as it is:

  • It runs as any UID. OpenShift assigns one from the namespace range and adds group 0, and everything the process writes is owned by group 0.
  • It needs no capabilities, no privilege escalation, no TUN device and no host networking, so capabilities: {drop: [ALL]} and allowPrivilegeEscalation: false hold.
  • With state in memory it needs no writable path, so readOnlyRootFilesystem: true holds.

The simplest shape is a sidecar. The application in the same pod reaches the forward on localhost, so the listener stays on loopback and needs neither NB_ALLOW_PUBLIC_BIND nor a Service:

containers:
  - name: nblink
    image: netbirdio/nblink
    env:
      - name: NB_FORWARD
        value: http://8080=https://grafana.internal
      - name: NB_SETUP_KEY
        value: file:/run/secrets/nblink/setup-key
    volumeMounts:
      - name: nblink-key
        mountPath: /run/secrets/nblink
        readOnly: true
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      runAsNonRoot: true
      capabilities: {drop: [ALL]}
      seccompProfile: {type: RuntimeDefault}
volumes:
  - name: nblink-key
    secret:
      secretName: nblink-setup-key
      defaultMode: 0440

A Secret volume is owned by the pod's fsGroup, which OpenShift sets for every pod, so mode 0440 is readable by the container and by nothing else.

To keep the identity across pod restarts, mount a persistent volume at /var/lib/nblink and set NB_STATE_DIR to it. fsGroup makes the volume writable by the arbitrary UID. A Deployment with more than one replica cannot share one state directory: every replica is its own peer, so give each one its own volume through a StatefulSet, or keep state in memory with an ephemeral setup key.

Serving a forward to other pods through a Service needs NB_ALLOW_PUBLIC_BIND and every name callers use in NB_ALLOWED_HOST, for example grafana-link,grafana-link.monitoring.svc,grafana-link.monitoring.svc.cluster.local. Each caller acts under the forwarder peer's identity, so restrict who can reach the Service with a NetworkPolicy. Do not expose it through a Route or an Ingress.

Configuration

Flag Environment Default Purpose
--forward NB_FORWARD Forward spec, repeatable. Comma separated in the environment.
--setup-key NB_SETUP_KEY Non-interactive login. Accepts a file: prefix.
--management-url NB_MANAGEMENT_URL NetBird cloud Management server.
--hostname NB_HOSTNAME host name Peer name in the network.
--state-dir NB_STATE_DIR memory Where identity and state persist.
--log-level NB_LOG_LEVEL info Log level.
--allow-public-bind NB_ALLOW_PUBLIC_BIND false Required before binding a non-loopback address.
--allowed-host NB_ALLOWED_HOST Extra name a listener accepts in Host, repeatable. Comma separated in the environment.
--no-browser NB_NO_BROWSER false Print the login URL instead of opening a browser.
--check NB_CHECK false Validate, print the effective forwards, exit.

A secret given as file:/path is read from that file, so a container can mount it instead of exposing it in the process environment.

Limits

nblink only dials out. It refuses inbound connections and does not reach the host's own network, so the peer cannot become a route into the machine it runs on.

A loopback forward answers only requests whose Host names the loopback interface or a name listed with --allowed-host, and refuses any request a browser marks as belonging to another page, through either a cross-origin Origin or a cross-site Sec-Fetch-Site. A page the user visits can point its own hostname at 127.0.0.1 or embed the address directly, and without those checks the browser could reach the upstream through this listener under the peer's identity. Ordinary callers send neither header and are unaffected.

A public forward applies the same checks, and also accepts any IP address in Host, since a page cannot rebind an address. The Host check matters there too: a container port published on the host's loopback is reached by a rebound page with a matching Origin, and its unlisted name is the only thing that gives it away.

A name listed with --allowed-host is trusted rather than merely permitted, and the list applies to every forward in the process, loopback ones included. Host is the one signal a rebound page cannot forge, and listing a name gives that up for it: a page served on that name can resolve it to the listener and send a matching Host and Origin, which the browser then marks same-origin. List only names you control, and prefer a fully qualified one, since a single-label name can be answered by anything on the local network.

A forward on 0.0.0.0 listens on IPv4 only, and one on :: on IPv6 only.

nblink does not ask the local router for a port mapping through UPnP, NAT-PMP or PCP, since an unprivileged forwarder should not reconfigure the network it runs on. Connections are still established through hole punching or the relay. Set NB_DISABLE_NAT_MAPPER=false to allow it.

A browser too old to send Sec-Fetch-Site, which browsers have sent since 2020, can still reach a loopback forward with an embedded no-cors GET.

Userspace mode carries TCP, UDP and ping only. It cannot act as an exit node or a routing peer and does not take over system DNS. Ports below 1024 need privileges the process usually does not have, though some container runtimes lower that boundary.

This build forwards HTTP. The tcp, udp and socks5 schemes are reserved by the grammar and rejected with a message saying so, so adding them later needs no change to how a forward is written.