mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 00:49:07 +02:00
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.
246 lines
8.0 KiB
Go
246 lines
8.0 KiB
Go
package link
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
)
|
|
|
|
// envPrefix matches the prefix the agent uses, so an operator who already sets
|
|
// NB_SETUP_KEY or NB_MANAGEMENT_URL for the agent can reuse them here.
|
|
const envPrefix = "NB_"
|
|
|
|
// fileScheme marks a flag value that holds a path to read the real value from,
|
|
// so a container can pass a secret as a mounted file instead of an environment
|
|
// variable visible to anything that can read the process environment.
|
|
const fileScheme = "file:"
|
|
|
|
// hostnamePattern matches a DNS name per RFC 1123 section 2.1: dot-separated
|
|
// labels of letters, digits and inner hyphens.
|
|
var hostnamePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
|
|
|
|
// Config is the resolved runtime configuration.
|
|
type Config struct {
|
|
Forwards []Forward
|
|
AllowedHosts []string
|
|
SetupKey string
|
|
ManagementURL string
|
|
Hostname string
|
|
StateDir string
|
|
LogLevel string
|
|
AllowPublicBind bool
|
|
NoBrowser bool
|
|
Check bool
|
|
}
|
|
|
|
// rawConfig holds flag values before parsing and validation.
|
|
type rawConfig struct {
|
|
forwards []string
|
|
allowedHosts []string
|
|
setupKey string
|
|
managementURL string
|
|
hostname string
|
|
stateDir string
|
|
logLevel string
|
|
allowPublicBind bool
|
|
noBrowser bool
|
|
check bool
|
|
}
|
|
|
|
// Resolve validates the raw flag values and produces a runtime configuration.
|
|
// It reports every problem it can rather than stopping at the first, so an
|
|
// operator fixing a container environment sees the whole list in one run.
|
|
func (r *rawConfig) Resolve() (*Config, error) {
|
|
cfg := &Config{
|
|
ManagementURL: r.managementURL,
|
|
Hostname: r.hostname,
|
|
StateDir: r.stateDir,
|
|
LogLevel: r.logLevel,
|
|
AllowPublicBind: r.allowPublicBind,
|
|
NoBrowser: r.noBrowser,
|
|
Check: r.check,
|
|
}
|
|
|
|
setupKey, err := resolveSecret(r.setupKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("setup key: %w", err)
|
|
}
|
|
cfg.SetupKey = setupKey
|
|
|
|
if len(r.forwards) == 0 {
|
|
return nil, fmt.Errorf("at least one --forward is required (env %s)", FlagNameToEnvVar("forward"))
|
|
}
|
|
|
|
var problems []string
|
|
cfg.AllowedHosts, problems = parseAllowedHosts(r.allowedHosts)
|
|
|
|
seen := make(map[string]string, len(r.forwards))
|
|
for _, spec := range r.forwards {
|
|
fwd, err := ParseForward(spec)
|
|
if err != nil {
|
|
problems = append(problems, err.Error())
|
|
continue
|
|
}
|
|
// Port 0 means the OS assigns one, so several such forwards do not
|
|
// collide even though they share a spec.
|
|
if prev, dup := seen[fwd.Listen]; dup && !strings.HasSuffix(fwd.Listen, ":0") {
|
|
problems = append(problems, fmt.Sprintf("forward %q: %s is already bound by %q", spec, fwd.Listen, prev))
|
|
continue
|
|
}
|
|
if !cfg.AllowPublicBind && !isLoopback(fwd.Listen) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"forward %q: %s is not loopback, pass --allow-public-bind (env %s) to expose it",
|
|
spec, fwd.Listen, FlagNameToEnvVar("allow-public-bind")))
|
|
continue
|
|
}
|
|
seen[fwd.Listen] = spec
|
|
fwd.AllowedHosts = cfg.AllowedHosts
|
|
cfg.Forwards = append(cfg.Forwards, fwd)
|
|
}
|
|
|
|
if len(problems) > 0 {
|
|
return nil, fmt.Errorf("invalid configuration:\n %s", strings.Join(problems, "\n "))
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// ConfigPath returns where the peer identity is persisted, or an empty string
|
|
// when the client should keep it in memory only.
|
|
func (c *Config) ConfigPath() string {
|
|
if c.StateDir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(c.StateDir, "config.json")
|
|
}
|
|
|
|
// StatePath returns where runtime state is persisted, or an empty string when
|
|
// no state directory was configured.
|
|
func (c *Config) StatePath() string {
|
|
if c.StateDir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(c.StateDir, "state.json")
|
|
}
|
|
|
|
// BindFlags registers nblink's flags on cmd and returns the backing values.
|
|
// Every flag gains an NB_-prefixed environment variable of the same name, so
|
|
// the container configuration cannot drift from the command line.
|
|
func BindFlags(cmd *cobra.Command) *rawConfig {
|
|
raw := &rawConfig{}
|
|
f := cmd.PersistentFlags()
|
|
|
|
f.StringSliceVar(&raw.forwards, "forward", nil,
|
|
"forward spec scheme://[host:]port=upstream, repeatable (env: comma separated)")
|
|
f.StringSliceVar(&raw.allowedHosts, "allowed-host", nil,
|
|
"extra name a listener may be reached under, repeatable (env: comma separated)")
|
|
f.StringVar(&raw.setupKey, "setup-key", "",
|
|
"setup key for non-interactive login, accepts a file: prefix")
|
|
f.StringVar(&raw.managementURL, "management-url", profilemanager.DefaultManagementURL,
|
|
"management server URL")
|
|
f.StringVar(&raw.hostname, "hostname", "", "peer name in the network")
|
|
f.StringVar(&raw.stateDir, "state-dir", "",
|
|
"directory for config and state, kept in memory when empty")
|
|
f.StringVar(&raw.logLevel, "log-level", "info", "log level")
|
|
f.BoolVar(&raw.allowPublicBind, "allow-public-bind", false,
|
|
"permit forwards that bind an address other than loopback")
|
|
f.BoolVar(&raw.noBrowser, "no-browser", false,
|
|
"print the login URL instead of opening a browser")
|
|
f.BoolVar(&raw.check, "check", false,
|
|
"validate configuration, print the effective forwards and exit")
|
|
|
|
return raw
|
|
}
|
|
|
|
// SetFlagsFromEnvVars fills unset flags from NB_-prefixed environment
|
|
// variables derived from each flag name, so --management-url reads
|
|
// NB_MANAGEMENT_URL. A flag given on the command line wins.
|
|
//
|
|
// A value that does not parse is an error rather than a warning, so a
|
|
// container cannot start with a configuration other than the one it was given.
|
|
func SetFlagsFromEnvVars(cmd *cobra.Command) error {
|
|
flags := cmd.PersistentFlags()
|
|
var err error
|
|
flags.VisitAll(func(f *pflag.Flag) {
|
|
if err != nil || f.Changed {
|
|
return
|
|
}
|
|
env := FlagNameToEnvVar(f.Name)
|
|
value, ok := os.LookupEnv(env)
|
|
if !ok {
|
|
return
|
|
}
|
|
if setErr := flags.Set(f.Name, value); setErr != nil {
|
|
err = fmt.Errorf("%s: %w", env, setErr)
|
|
}
|
|
})
|
|
return err
|
|
}
|
|
|
|
// FlagNameToEnvVar converts a flag name to its environment variable, so
|
|
// allow-public-bind becomes NB_ALLOW_PUBLIC_BIND.
|
|
func FlagNameToEnvVar(name string) string {
|
|
return envPrefix + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
|
}
|
|
|
|
// parseAllowedHosts normalizes the names given with --allowed-host and reports
|
|
// every entry that is not a plain hostname.
|
|
func parseAllowedHosts(raw []string) ([]string, []string) {
|
|
var hosts, problems []string
|
|
for _, entry := range raw {
|
|
name := normalizeHostname(entry)
|
|
if _, err := netip.ParseAddr(name); err == nil {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"allowed host %q: addresses are already accepted on a public listener, list names only", entry))
|
|
continue
|
|
}
|
|
if !hostnamePattern.MatchString(name) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"allowed host %q: must be a hostname, without scheme, port or wildcard", entry))
|
|
continue
|
|
}
|
|
hosts = append(hosts, name)
|
|
}
|
|
return hosts, problems
|
|
}
|
|
|
|
// normalizeHostname lowercases a name and drops a trailing root dot, so the
|
|
// spellings a client may send compare equal.
|
|
func normalizeHostname(name string) string {
|
|
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
|
}
|
|
|
|
// resolveSecret reads a value that may carry a file: prefix naming the file
|
|
// that holds it. Whitespace around the file contents is trimmed so a trailing
|
|
// newline from the usual tooling does not become part of the secret.
|
|
func resolveSecret(value string) (string, error) {
|
|
path, ok := strings.CutPrefix(value, fileScheme)
|
|
if !ok {
|
|
return value, nil
|
|
}
|
|
if path == "" {
|
|
return "", fmt.Errorf("%s prefix with no path", fileScheme)
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
// An empty file would otherwise look like no secret at all, silently
|
|
// turning a non-interactive start into a login that waits for a browser.
|
|
secret := strings.TrimSpace(string(content))
|
|
if secret == "" {
|
|
return "", fmt.Errorf("%s is empty", path)
|
|
}
|
|
return secret, nil
|
|
}
|