[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.
This commit is contained in:
Maycon Santos
2026-10-12 13:54:15 +02:00
committed by GitHub
parent fe7841721a
commit 66a28fd666
28 changed files with 4891 additions and 71 deletions
+13 -3
View File
@@ -54,6 +54,9 @@ type Client struct {
jwtToken string
connect *internal.ConnectClient
recorder *peer.Status
// inMemoryState is set when no StatePath was given in netstack mode,
// where the client changes nothing on the host that needs restoring.
inMemoryState bool
}
// Options configures a new Client.
@@ -78,7 +81,8 @@ type Options struct {
NoUserspace bool
// ConfigPath is the path to the netbird config file. If empty, the config will be stored in memory and not persisted.
ConfigPath string
// StatePath is the path to the netbird state file
// StatePath is the path to the netbird state file. When empty in netstack
// mode, state is kept in memory; with NoUserspace the default path is used.
StatePath string
// DisableClientRoutes disables the client routes
DisableClientRoutes bool
@@ -193,7 +197,6 @@ func New(opts Options) (*Client, error) {
}
if opts.StatePath != "" {
// TODO: Disable state if path not provided
if err := os.Setenv("NB_DNS_STATE_FILE", opts.StatePath); err != nil {
return nil, fmt.Errorf("setenv: %w", err)
}
@@ -261,6 +264,9 @@ func New(opts Options) (*Client, error) {
jwtToken: opts.JWTToken,
config: config,
recorder: peer.NewRecorder(config.ManagementURL.String()),
// Without a path the default would be the installed agent's state
// file, which this client must neither read nor overwrite.
inMemoryState: opts.StatePath == "" && !opts.NoUserspace,
}, nil
}
@@ -292,7 +298,11 @@ func (c *Client) Start(startCtx context.Context) error {
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
return fmt.Errorf("login: %w", err)
}
client := internal.NewConnectClient(ctx, c.config, c.recorder)
var connectOpts []internal.ConnectClientOption
if c.inMemoryState {
connectOpts = append(connectOpts, internal.WithInMemoryState())
}
client := internal.NewConnectClient(ctx, c.config, c.recorder, connectOpts...)
client.SetSyncResponsePersistence(true)
// either startup error (permanent backoff err) or nil err (successful engine up)