Files
netbird/client/link/auth.go
T
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

102 lines
3.0 KiB
Go

package link
import (
"context"
"fmt"
"io"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/util"
)
// credentials carries exactly one proof of identity to the embedded client.
type credentials struct {
setupKey string
jwtToken string
}
// resolveCredentials returns a setup key when one was supplied, and otherwise
// drives the interactive login flow.
//
// The embedded client accepts a setup key or an already-issued token, so the
// browser flow is run here and only its result is handed over.
func resolveCredentials(ctx context.Context, cfg *Config) (credentials, error) {
if cfg.SetupKey != "" {
log.Info("authenticating with the configured setup key")
return credentials{setupKey: cfg.SetupKey}, nil
}
log.Info("no setup key configured, starting interactive login")
token, err := interactiveLogin(ctx, cfg)
if err != nil {
return credentials{}, err
}
return credentials{jwtToken: token}, nil
}
// interactiveLogin runs the standard browser login and returns the token to
// authenticate the peer with.
func interactiveLogin(ctx context.Context, cfg *Config) (string, error) {
// The flow only needs somewhere to read the management URL from and a key
// to talk to it with. The peer identity the client ends up using is
// created separately, so this config is deliberately not persisted.
flowConfig, err := profilemanager.CreateInMemoryConfig(profilemanager.ConfigInput{
ManagementURL: cfg.ManagementURL,
})
if err != nil {
return "", fmt.Errorf("prepare login: %w", err)
}
flow, err := auth.NewOAuthFlow(ctx, flowConfig, util.HasGraphicalSession(), false, "", false)
if err != nil {
return "", fmt.Errorf("start login: %w", err)
}
info, err := flow.RequestAuthInfo(ctx)
if err != nil {
return "", fmt.Errorf("request login: %w", err)
}
promptLogin(os.Stderr, util.OpenBrowser, info, cfg.NoBrowser)
token, err := flow.WaitToken(ctx, info)
if err != nil {
return "", fmt.Errorf("wait for login: %w", err)
}
return token.GetTokenToUse(), nil
}
// promptLogin writes the verification URL to out, which the caller points at
// stderr so stdout stays clean for piping, and opens a browser unless asked not
// to. The URL is always printed so the flow still works over SSH where no
// browser can open.
func promptLogin(out io.Writer, openBrowser func(string) error, info auth.AuthFlowInfo, noBrowser bool) {
uri := info.VerificationURIComplete
if uri == "" {
uri = info.VerificationURI
}
var b strings.Builder
fmt.Fprintf(&b, "\nOpen this URL to log in:\n\n %s\n", uri)
if info.UserCode != "" && !strings.Contains(uri, info.UserCode) {
fmt.Fprintf(&b, "\n and enter the code %s\n", info.UserCode)
}
b.WriteString("\n")
if _, err := io.WriteString(out, b.String()); err != nil {
log.Warnf("print login URL: %v", err)
}
if noBrowser {
return
}
if err := openBrowser(uri); err != nil {
log.Debugf("could not open a browser: %v", err)
}
}