From 64b849c801eba6046a555770b3ddb885dae84d62 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 12 Feb 2026 19:24:43 +0100 Subject: [PATCH] [self-hosted] add netbird server (#5232) * Unified NetBird combined server (Management, Signal, Relay, STUN) as a single executable with richer YAML configuration, validation, and defaults. * Official Dockerfile/image for single-container deployment. * Optional in-process profiling endpoint for diagnostics. * Multiplexing to route HTTP/gRPC/WebSocket traffic via one port; runtime hooks to inject custom handlers. * **Chores** * Updated deployment scripts, compose files, and reverse-proxy templates to target the combined server; added example configs and getting-started updates. --- .goreleaser.yaml | 94 +++ combined/Dockerfile | 5 + combined/cmd/config.go | 715 +++++++++++++++++++ combined/cmd/pprof.go | 33 + combined/cmd/root.go | 711 +++++++++++++++++++ combined/config-simple.yaml.example | 111 +++ combined/config.yaml.example | 115 +++ combined/main.go | 13 + infrastructure_files/getting-started.sh | 778 +++++++-------------- management/cmd/management.go | 46 +- management/cmd/management_test.go | 2 +- management/internals/server/server.go | 33 +- management/server/store/sql_store.go | 4 +- management/server/store/store.go | 18 +- management/server/telemetry/app_metrics.go | 50 ++ relay/cmd/root.go | 2 +- relay/server/server.go | 8 + {signal => shared}/metrics/metrics.go | 0 signal/cmd/root.go | 1 - signal/cmd/run.go | 28 +- signal/metrics/app.go | 28 +- signal/server/signal.go | 4 +- stun/server.go | 2 +- 23 files changed, 2198 insertions(+), 603 deletions(-) create mode 100644 combined/Dockerfile create mode 100644 combined/cmd/config.go create mode 100644 combined/cmd/pprof.go create mode 100644 combined/cmd/root.go create mode 100644 combined/config-simple.yaml.example create mode 100644 combined/config.yaml.example create mode 100644 combined/main.go rename {signal => shared}/metrics/metrics.go (100%) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7c6651f83..743822649 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -106,6 +106,26 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + - id: netbird-server + dir: combined + env: + - CGO_ENABLED=1 + - >- + {{- if eq .Runtime.Goos "linux" }} + {{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }} + {{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }} + {{- end }} + binary: netbird-server + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + - id: netbird-upload dir: upload-server env: [CGO_ENABLED=0] @@ -520,6 +540,55 @@ dockers: - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-amd64 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + ids: + - netbird-server + goarch: amd64 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + ids: + - netbird-server + goarch: arm64 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + ids: + - netbird-server + goarch: arm + goarm: 6 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/arm" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" docker_manifests: - name_template: netbirdio/netbird:{{ .Version }} image_templates: @@ -598,6 +667,18 @@ docker_manifests: - netbirdio/upload:{{ .Version }}-arm - netbirdio/upload:{{ .Version }}-amd64 + - name_template: netbirdio/netbird-server:{{ .Version }} + image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - netbirdio/netbird-server:{{ .Version }}-arm + - netbirdio/netbird-server:{{ .Version }}-amd64 + + - name_template: netbirdio/netbird-server:latest + image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - netbirdio/netbird-server:{{ .Version }}-arm + - netbirdio/netbird-server:{{ .Version }}-amd64 + - name_template: ghcr.io/netbirdio/netbird:{{ .Version }} image_templates: - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 @@ -675,6 +756,19 @@ docker_manifests: - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }} + image_templates: + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/netbird-server:latest + image_templates: + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + brews: - ids: - default diff --git a/combined/Dockerfile b/combined/Dockerfile new file mode 100644 index 000000000..357e10cf8 --- /dev/null +++ b/combined/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-server" ] +CMD ["--config", "/etc/netbird/config.yaml"] +COPY netbird-server /go/bin/netbird-server \ No newline at end of file diff --git a/combined/cmd/config.go b/combined/cmd/config.go new file mode 100644 index 000000000..72c63b7c7 --- /dev/null +++ b/combined/cmd/config.go @@ -0,0 +1,715 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + "path" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +// CombinedConfig is the root configuration for the combined server. +// The combined server is primarily a Management server with optional embedded +// Signal, Relay, and STUN services. +// +// Architecture: +// - Management: Always runs locally (this IS the management server) +// - Signal: Runs locally by default; disabled if server.signalUri is set +// - Relay: Runs locally by default; disabled if server.relays is set +// - STUN: Runs locally on port 3478 by default; disabled if server.stuns is set +// +// All user-facing settings are under "server". The relay/signal/management +// fields are internal and populated automatically from server settings. +type CombinedConfig struct { + Server ServerConfig `yaml:"server"` + + // Internal configs - populated from Server settings, not user-configurable + Relay RelayConfig `yaml:"-"` + Signal SignalConfig `yaml:"-"` + Management ManagementConfig `yaml:"-"` +} + +// ServerConfig contains server-wide settings +// In simplified mode, this contains all configuration +type ServerConfig struct { + ListenAddress string `yaml:"listenAddress"` + MetricsPort int `yaml:"metricsPort"` + HealthcheckAddress string `yaml:"healthcheckAddress"` + LogLevel string `yaml:"logLevel"` + LogFile string `yaml:"logFile"` + TLS TLSConfig `yaml:"tls"` + + // Simplified config fields (used when relay/signal/management sections are omitted) + ExposedAddress string `yaml:"exposedAddress"` // Public address with protocol (e.g., "https://example.com:443") + StunPorts []int `yaml:"stunPorts"` // STUN ports (empty to disable local STUN) + AuthSecret string `yaml:"authSecret"` // Shared secret for relay authentication + DataDir string `yaml:"dataDir"` // Data directory for all services + + // External service overrides (simplified mode) + // When these are set, the corresponding local service is NOT started + // and these values are used for client configuration instead + Stuns []HostConfig `yaml:"stuns"` // External STUN servers (disables local STUN) + Relays RelaysConfig `yaml:"relays"` // External relay servers (disables local relay) + SignalURI string `yaml:"signalUri"` // External signal server (disables local signal) + + // Management settings (simplified mode) + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + Auth AuthConfig `yaml:"auth"` + Store StoreConfig `yaml:"store"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` +} + +// TLSConfig contains TLS/HTTPS settings +type TLSConfig struct { + CertFile string `yaml:"certFile"` + KeyFile string `yaml:"keyFile"` + LetsEncrypt LetsEncryptConfig `yaml:"letsencrypt"` +} + +// LetsEncryptConfig contains Let's Encrypt settings +type LetsEncryptConfig struct { + Enabled bool `yaml:"enabled"` + DataDir string `yaml:"dataDir"` + Domains []string `yaml:"domains"` + Email string `yaml:"email"` + AWSRoute53 bool `yaml:"awsRoute53"` +} + +// RelayConfig contains relay service settings +type RelayConfig struct { + Enabled bool `yaml:"enabled"` + ExposedAddress string `yaml:"exposedAddress"` + AuthSecret string `yaml:"authSecret"` + LogLevel string `yaml:"logLevel"` + Stun StunConfig `yaml:"stun"` +} + +// StunConfig contains embedded STUN service settings +type StunConfig struct { + Enabled bool `yaml:"enabled"` + Ports []int `yaml:"ports"` + LogLevel string `yaml:"logLevel"` +} + +// SignalConfig contains signal service settings +type SignalConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` +} + +// ManagementConfig contains management service settings +type ManagementConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` + DataDir string `yaml:"dataDir"` + DnsDomain string `yaml:"dnsDomain"` + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + DisableDefaultPolicy bool `yaml:"disableDefaultPolicy"` + Auth AuthConfig `yaml:"auth"` + Stuns []HostConfig `yaml:"stuns"` + Relays RelaysConfig `yaml:"relays"` + SignalURI string `yaml:"signalUri"` + Store StoreConfig `yaml:"store"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` +} + +// AuthConfig contains authentication/identity provider settings +type AuthConfig struct { + Issuer string `yaml:"issuer"` + LocalAuthDisabled bool `yaml:"localAuthDisabled"` + SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` + Storage AuthStorageConfig `yaml:"storage"` + DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"` + CLIRedirectURIs []string `yaml:"cliRedirectURIs"` + Owner *AuthOwnerConfig `yaml:"owner,omitempty"` +} + +// AuthStorageConfig contains auth storage settings +type AuthStorageConfig struct { + Type string `yaml:"type"` + File string `yaml:"file"` +} + +// AuthOwnerConfig contains initial admin user settings +type AuthOwnerConfig struct { + Email string `yaml:"email"` + Password string `yaml:"password"` +} + +// HostConfig represents a STUN/TURN/Signal host +type HostConfig struct { + URI string `yaml:"uri"` + Proto string `yaml:"proto,omitempty"` // udp, dtls, tcp, http, https - defaults based on URI scheme + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} + +// RelaysConfig contains external relay server settings for clients +type RelaysConfig struct { + Addresses []string `yaml:"addresses"` + CredentialsTTL string `yaml:"credentialsTTL"` + Secret string `yaml:"secret"` +} + +// StoreConfig contains database settings +type StoreConfig struct { + Engine string `yaml:"engine"` + EncryptionKey string `yaml:"encryptionKey"` + DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines +} + +// ReverseProxyConfig contains reverse proxy settings +type ReverseProxyConfig struct { + TrustedHTTPProxies []string `yaml:"trustedHTTPProxies"` + TrustedHTTPProxiesCount uint `yaml:"trustedHTTPProxiesCount"` + TrustedPeers []string `yaml:"trustedPeers"` +} + +// DefaultConfig returns a CombinedConfig with default values +func DefaultConfig() *CombinedConfig { + return &CombinedConfig{ + Server: ServerConfig{ + ListenAddress: ":443", + MetricsPort: 9090, + HealthcheckAddress: ":9000", + LogLevel: "info", + LogFile: "console", + StunPorts: []int{3478}, + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + Relay: RelayConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + Stun: StunConfig{ + Enabled: false, + Ports: []int{3478}, + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + }, + Signal: SignalConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + Management: ManagementConfig{ + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Relays: RelaysConfig{ + CredentialsTTL: "12h", + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + } +} + +// hasRequiredSettings returns true if the configuration has the required server settings +func (c *CombinedConfig) hasRequiredSettings() bool { + return c.Server.ExposedAddress != "" +} + +// parseExposedAddress extracts protocol, host, and host:port from the exposed address +// Input format: "https://example.com:443" or "http://example.com:8080" or "example.com:443" +// Returns: protocol ("https" or "http"), hostname only, and host:port +func parseExposedAddress(exposedAddress string) (protocol, hostname, hostPort string) { + // Default to https if no protocol specified + protocol = "https" + hostPort = exposedAddress + + // Check for protocol prefix + if strings.HasPrefix(exposedAddress, "https://") { + protocol = "https" + hostPort = strings.TrimPrefix(exposedAddress, "https://") + } else if strings.HasPrefix(exposedAddress, "http://") { + protocol = "http" + hostPort = strings.TrimPrefix(exposedAddress, "http://") + } + + // Extract hostname (without port) + hostname = hostPort + if host, _, err := net.SplitHostPort(hostPort); err == nil { + hostname = host + } + + return protocol, hostname, hostPort +} + +// ApplySimplifiedDefaults populates internal relay/signal/management configs from server settings. +// Management is always enabled. Signal, Relay, and STUN are enabled unless external +// overrides are configured (server.signalUri, server.relays, server.stuns). +func (c *CombinedConfig) ApplySimplifiedDefaults() { + if !c.hasRequiredSettings() { + return + } + + // Parse exposed address to extract protocol and hostname + exposedProto, exposedHost, exposedHostPort := parseExposedAddress(c.Server.ExposedAddress) + + // Check for external service overrides + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + hasExternalSignal := c.Server.SignalURI != "" + hasExternalStuns := len(c.Server.Stuns) > 0 + + // Default stunPorts to [3478] if not specified and no external STUN + if len(c.Server.StunPorts) == 0 && !hasExternalStuns { + c.Server.StunPorts = []int{3478} + } + + c.applyRelayDefaults(exposedProto, exposedHostPort, hasExternalRelay, hasExternalStuns) + c.applySignalDefaults(hasExternalSignal) + c.applyManagementDefaults(exposedHost) + + // Auto-configure client settings (stuns, relays, signalUri) + c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) +} + +// applyRelayDefaults configures the relay service if no external relay is configured. +func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { + if hasExternalRelay { + return + } + + c.Relay.Enabled = true + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + c.Relay.ExposedAddress = fmt.Sprintf("%s://%s", relayProto, exposedHostPort) + c.Relay.AuthSecret = c.Server.AuthSecret + if c.Relay.LogLevel == "" { + c.Relay.LogLevel = c.Server.LogLevel + } + + // Enable local STUN only if no external STUN servers and stunPorts are configured + if !hasExternalStuns && len(c.Server.StunPorts) > 0 { + c.Relay.Stun.Enabled = true + c.Relay.Stun.Ports = c.Server.StunPorts + if c.Relay.Stun.LogLevel == "" { + c.Relay.Stun.LogLevel = c.Server.LogLevel + } + } +} + +// applySignalDefaults configures the signal service if no external signal is configured. +func (c *CombinedConfig) applySignalDefaults(hasExternalSignal bool) { + if hasExternalSignal { + return + } + + c.Signal.Enabled = true + if c.Signal.LogLevel == "" { + c.Signal.LogLevel = c.Server.LogLevel + } +} + +// applyManagementDefaults configures the management service (always enabled). +func (c *CombinedConfig) applyManagementDefaults(exposedHost string) { + c.Management.Enabled = true + if c.Management.LogLevel == "" { + c.Management.LogLevel = c.Server.LogLevel + } + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + c.Management.DnsDomain = exposedHost + c.Management.DisableAnonymousMetrics = c.Server.DisableAnonymousMetrics + c.Management.DisableGeoliteUpdate = c.Server.DisableGeoliteUpdate + // Copy auth config from server if management auth issuer is not set + if c.Management.Auth.Issuer == "" && c.Server.Auth.Issuer != "" { + c.Management.Auth = c.Server.Auth + } + + // Copy store config from server if not set + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" { + c.Management.Store = c.Server.Store + } + } + + // Copy reverse proxy config from server + if len(c.Server.ReverseProxy.TrustedHTTPProxies) > 0 || c.Server.ReverseProxy.TrustedHTTPProxiesCount > 0 || len(c.Server.ReverseProxy.TrustedPeers) > 0 { + c.Management.ReverseProxy = c.Server.ReverseProxy + } +} + +// autoConfigureClientSettings sets up STUN/relay/signal URIs for clients +// External overrides from server config take precedence over auto-generated values +func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort string, hasExternalStuns, hasExternalRelay, hasExternalSignal bool) { + // Determine relay protocol from exposed protocol + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + + // Configure STUN servers for clients + if hasExternalStuns { + // Use external STUN servers from server config + c.Management.Stuns = c.Server.Stuns + } else if len(c.Server.StunPorts) > 0 && len(c.Management.Stuns) == 0 { + // Auto-configure local STUN servers for all ports + for _, port := range c.Server.StunPorts { + c.Management.Stuns = append(c.Management.Stuns, HostConfig{ + URI: fmt.Sprintf("stun:%s:%d", exposedHost, port), + }) + } + } + + // Configure relay for clients + if hasExternalRelay { + // Use external relay config from server + c.Management.Relays = c.Server.Relays + } else if len(c.Management.Relays.Addresses) == 0 { + // Auto-configure local relay + c.Management.Relays.Addresses = []string{ + fmt.Sprintf("%s://%s", relayProto, exposedHostPort), + } + } + if c.Management.Relays.Secret == "" { + c.Management.Relays.Secret = c.Server.AuthSecret + } + if c.Management.Relays.CredentialsTTL == "" { + c.Management.Relays.CredentialsTTL = "12h" + } + + // Configure signal for clients + if hasExternalSignal { + // Use external signal URI from server config + c.Management.SignalURI = c.Server.SignalURI + } else if c.Management.SignalURI == "" { + // Auto-configure local signal + c.Management.SignalURI = fmt.Sprintf("%s://%s", exposedProto, exposedHostPort) + } +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(configPath string) (*CombinedConfig, error) { + cfg := DefaultConfig() + + if configPath == "" { + return cfg, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Populate internal configs from server settings + cfg.ApplySimplifiedDefaults() + + return cfg, nil +} + +// Validate validates the configuration +func (c *CombinedConfig) Validate() error { + if c.Server.ExposedAddress == "" { + return fmt.Errorf("server.exposedAddress is required") + } + if c.Server.DataDir == "" { + return fmt.Errorf("server.dataDir is required") + } + + // Validate STUN ports + seen := make(map[int]bool) + for _, port := range c.Server.StunPorts { + if port <= 0 || port > 65535 { + return fmt.Errorf("invalid server.stunPorts value %d: must be between 1 and 65535", port) + } + if seen[port] { + return fmt.Errorf("duplicate STUN port %d in server.stunPorts", port) + } + seen[port] = true + } + + // authSecret is required only if running local relay (no external relay configured) + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + if !hasExternalRelay && c.Server.AuthSecret == "" { + return fmt.Errorf("server.authSecret is required when running local relay") + } + + return nil +} + +// HasTLSCert returns true if TLS certificate files are configured +func (c *CombinedConfig) HasTLSCert() bool { + return c.Server.TLS.CertFile != "" && c.Server.TLS.KeyFile != "" +} + +// HasLetsEncrypt returns true if Let's Encrypt is configured +func (c *CombinedConfig) HasLetsEncrypt() bool { + return c.Server.TLS.LetsEncrypt.Enabled && + c.Server.TLS.LetsEncrypt.DataDir != "" && + len(c.Server.TLS.LetsEncrypt.Domains) > 0 +} + +// parseExplicitProtocol parses an explicit protocol string to nbconfig.Protocol +func parseExplicitProtocol(proto string) (nbconfig.Protocol, bool) { + switch strings.ToLower(proto) { + case "udp": + return nbconfig.UDP, true + case "dtls": + return nbconfig.DTLS, true + case "tcp": + return nbconfig.TCP, true + case "http": + return nbconfig.HTTP, true + case "https": + return nbconfig.HTTPS, true + default: + return "", false + } +} + +// parseStunProtocol determines protocol for STUN/TURN servers. +// stun: → UDP, stuns: → DTLS, turn: → UDP, turns: → DTLS +// Explicit proto overrides URI scheme. Defaults to UDP. +func parseStunProtocol(uri, proto string) nbconfig.Protocol { + if proto != "" { + if p, ok := parseExplicitProtocol(proto); ok { + return p + } + } + + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "stuns:"): + return nbconfig.DTLS + case strings.HasPrefix(uri, "turns:"): + return nbconfig.DTLS + default: + // stun:, turn:, or no scheme - default to UDP + return nbconfig.UDP + } +} + +// parseSignalProtocol determines protocol for Signal servers. +// https:// → HTTPS, http:// → HTTP. Defaults to HTTPS. +func parseSignalProtocol(uri string) nbconfig.Protocol { + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "http://"): + return nbconfig.HTTP + default: + // https:// or no scheme - default to HTTPS + return nbconfig.HTTPS + } +} + +// stripSignalProtocol removes the protocol prefix from a signal URI. +// Returns just the host:port (e.g., "selfhosted2.demo.netbird.io:443"). +func stripSignalProtocol(uri string) string { + uri = strings.TrimPrefix(uri, "https://") + uri = strings.TrimPrefix(uri, "http://") + return uri +} + +// ToManagementConfig converts CombinedConfig to management server config +func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { + mgmt := c.Management + + // Build STUN hosts + var stuns []*nbconfig.Host + for _, s := range mgmt.Stuns { + stuns = append(stuns, &nbconfig.Host{ + URI: s.URI, + Proto: parseStunProtocol(s.URI, s.Proto), + Username: s.Username, + Password: s.Password, + }) + } + + // Build relay config + var relayConfig *nbconfig.Relay + if len(mgmt.Relays.Addresses) > 0 || mgmt.Relays.Secret != "" { + var ttl time.Duration + if mgmt.Relays.CredentialsTTL != "" { + var err error + ttl, err = time.ParseDuration(mgmt.Relays.CredentialsTTL) + if err != nil { + return nil, fmt.Errorf("invalid relay credentials TTL %q: %w", mgmt.Relays.CredentialsTTL, err) + } + } + relayConfig = &nbconfig.Relay{ + Addresses: mgmt.Relays.Addresses, + CredentialsTTL: util.Duration{Duration: ttl}, + Secret: mgmt.Relays.Secret, + } + } + + // Build signal config + var signalConfig *nbconfig.Host + if mgmt.SignalURI != "" { + signalConfig = &nbconfig.Host{ + URI: stripSignalProtocol(mgmt.SignalURI), + Proto: parseSignalProtocol(mgmt.SignalURI), + } + } + + // Build store config + storeConfig := nbconfig.StoreConfig{ + Engine: types.Engine(mgmt.Store.Engine), + } + + // Build reverse proxy config + reverseProxy := nbconfig.ReverseProxy{ + TrustedHTTPProxiesCount: mgmt.ReverseProxy.TrustedHTTPProxiesCount, + } + for _, p := range mgmt.ReverseProxy.TrustedHTTPProxies { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedHTTPProxies = append(reverseProxy.TrustedHTTPProxies, prefix) + } + } + for _, p := range mgmt.ReverseProxy.TrustedPeers { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedPeers = append(reverseProxy.TrustedPeers, prefix) + } + } + + // Build HTTP config (required, even if empty) + httpConfig := &nbconfig.HttpServerConfig{} + + // Build embedded IDP config (always enabled in combined server) + storageFile := mgmt.Auth.Storage.File + if storageFile == "" { + storageFile = path.Join(mgmt.DataDir, "idp.db") + } + + embeddedIdP := &idp.EmbeddedIdPConfig{ + Enabled: true, + Issuer: mgmt.Auth.Issuer, + LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled, + SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled, + Storage: idp.EmbeddedStorageConfig{ + Type: mgmt.Auth.Storage.Type, + Config: idp.EmbeddedStorageTypeConfig{ + File: storageFile, + }, + }, + DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, + CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + } + + if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { + embeddedIdP.Owner = &idp.OwnerConfig{ + Email: mgmt.Auth.Owner.Email, + Hash: mgmt.Auth.Owner.Password, // Will be hashed if plain text + } + } + + // Set HTTP config fields for embedded IDP + httpConfig.AuthIssuer = mgmt.Auth.Issuer + httpConfig.IdpSignKeyRefreshEnabled = mgmt.Auth.SignKeyRefreshEnabled + + return &nbconfig.Config{ + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + }, nil +} + +// ApplyEmbeddedIdPConfig applies embedded IdP configuration to the management config. +// This mirrors the logic in management/cmd/management.go ApplyEmbeddedIdPConfig. +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort int, disableSingleAccMode bool) error { + if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { + return nil + } + + // Embedded IdP requires single account mode + if disableSingleAccMode { + return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP") + } + + // Set LocalAddress for embedded IdP, used for internal JWT validation + cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort) + + // Set storage defaults based on Datadir + if cfg.EmbeddedIdP.Storage.Type == "" { + cfg.EmbeddedIdP.Storage.Type = "sqlite3" + } + if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { + cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + } + + issuer := cfg.EmbeddedIdP.Issuer + + // Ensure HttpConfig exists + if cfg.HttpConfig == nil { + cfg.HttpConfig = &nbconfig.HttpServerConfig{} + } + + // Set HttpConfig values from EmbeddedIdP + cfg.HttpConfig.AuthIssuer = issuer + cfg.HttpConfig.AuthAudience = "netbird-dashboard" + cfg.HttpConfig.CLIAuthAudience = "netbird-cli" + cfg.HttpConfig.AuthUserIDClaim = "sub" + cfg.HttpConfig.AuthKeysLocation = issuer + "/keys" + cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration" + cfg.HttpConfig.IdpSignKeyRefreshEnabled = true + + return nil +} + +// EnsureEncryptionKey generates an encryption key if not set. +// Unlike management server, we don't write back to the config file. +func EnsureEncryptionKey(ctx context.Context, cfg *nbconfig.Config) error { + if cfg.DataStoreEncryptionKey != "" { + return nil + } + + log.WithContext(ctx).Infof("DataStoreEncryptionKey is not set, generating a new key") + key, err := crypt.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate datastore encryption key: %v", err) + } + cfg.DataStoreEncryptionKey = key + keyPreview := key[:8] + "..." + log.WithContext(ctx).Warnf("DataStoreEncryptionKey generated (%s); add it to your config file under 'server.store.encryptionKey' to persist across restarts", keyPreview) + + return nil +} + +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { + if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled { + log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) + } + if cfg.Relay != nil { + log.Infof("Relay addresses: %v", cfg.Relay.Addresses) + } +} diff --git a/combined/cmd/pprof.go b/combined/cmd/pprof.go new file mode 100644 index 000000000..37efd35f0 --- /dev/null +++ b/combined/cmd/pprof.go @@ -0,0 +1,33 @@ +//go:build pprof +// +build pprof + +package cmd + +import ( + "net/http" + _ "net/http/pprof" + "os" + + log "github.com/sirupsen/logrus" +) + +func init() { + addr := pprofAddr() + go pprof(addr) +} + +func pprofAddr() string { + listenAddr := os.Getenv("NB_PPROF_ADDR") + if listenAddr == "" { + return "localhost:6969" + } + + return listenAddr +} + +func pprof(listenAddr string) { + log.Infof("listening pprof on: %s\n", listenAddr) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("Failed to start pprof: %v", err) + } +} diff --git a/combined/cmd/root.go b/combined/cmd/root.go new file mode 100644 index 000000000..8837fea44 --- /dev/null +++ b/combined/cmd/root.go @@ -0,0 +1,711 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/encryption" + mgmtServer "github.com/netbirdio/netbird/management/internals/server" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/relay/healthcheck" + relayServer "github.com/netbirdio/netbird/relay/server" + "github.com/netbirdio/netbird/relay/server/listener/ws" + sharedMetrics "github.com/netbirdio/netbird/shared/metrics" + "github.com/netbirdio/netbird/shared/relay/auth" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/wsproxy" + wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server" +) + +var ( + configPath string + config *CombinedConfig + + rootCmd = &cobra.Command{ + Use: "combined", + Short: "Combined Netbird server (Management + Signal + Relay + STUN)", + Long: `Combined Netbird server for self-hosted deployments. + +All services (Management, Signal, Relay) are multiplexed on a single port. +Optional STUN server runs on separate UDP ports. + +Configuration is loaded from a YAML file specified with --config.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: execute, + } +) + +func init() { + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") + _ = rootCmd.MarkPersistentFlagRequired("config") +} + +func Execute() error { + return rootCmd.Execute() +} + +func waitForExitSignal() { + osSigs := make(chan os.Signal, 1) + signal.Notify(osSigs, syscall.SIGINT, syscall.SIGTERM) + <-osSigs +} + +func execute(cmd *cobra.Command, _ []string) error { + if err := initializeConfig(); err != nil { + return err + } + + // Management is required as the base server when signal or relay are enabled + if (config.Signal.Enabled || config.Relay.Enabled) && !config.Management.Enabled { + return fmt.Errorf("management must be enabled when signal or relay are enabled (provides the base HTTP server)") + } + + servers, err := createAllServers(cmd.Context(), config) + if err != nil { + return err + } + + // Register services with management's gRPC server using AfterInit hook + setupServerHooks(servers, config) + + // Start management server (this also starts the HTTP listener) + if servers.mgmtSrv != nil { + if err := servers.mgmtSrv.Start(cmd.Context()); err != nil { + cleanupSTUNListeners(servers.stunListeners) + return fmt.Errorf("failed to start management server: %w", err) + } + } + + // Start all other servers + wg := sync.WaitGroup{} + startServers(&wg, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.metricsServer) + + waitForExitSignal() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = shutdownServers(ctx, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.mgmtSrv, servers.metricsServer) + wg.Wait() + return err +} + +// initializeConfig loads and validates the configuration, then initializes logging. +func initializeConfig() error { + var err error + config, err = LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := config.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + if err := util.InitLog(config.Server.LogLevel, config.Server.LogFile); err != nil { + return fmt.Errorf("failed to initialize log: %w", err) + } + + if dsn := config.Server.Store.DSN; dsn != "" { + switch strings.ToLower(config.Server.Store.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + + log.Infof("Starting combined NetBird server") + logConfig(config) + logEnvVars() + return nil +} + +// serverInstances holds all server instances created during startup. +type serverInstances struct { + relaySrv *relayServer.Server + mgmtSrv *mgmtServer.BaseServer + signalSrv *signalServer.Server + healthcheck *healthcheck.Server + stunServer *stun.Server + stunListeners []*net.UDPConn + metricsServer *sharedMetrics.Metrics +} + +// createAllServers creates all server instances based on configuration. +func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstances, error) { + metricsServer, err := sharedMetrics.NewServer(cfg.Server.MetricsPort, "") + if err != nil { + return nil, fmt.Errorf("failed to create metrics server: %w", err) + } + servers := &serverInstances{ + metricsServer: metricsServer, + } + + _, tlsSupport, err := handleTLSConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to setup TLS config: %w", err) + } + + if err := servers.createRelayServer(cfg, tlsSupport); err != nil { + return nil, err + } + + if err := servers.createManagementServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createSignalServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createHealthcheckServer(cfg); err != nil { + return nil, err + } + + return servers, nil +} + +func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool) error { + if !cfg.Relay.Enabled { + return nil + } + + var err error + s.stunListeners, err = createSTUNListeners(cfg) + if err != nil { + return err + } + + hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + + relayCfg := relayServer.Config{ + Meter: s.metricsServer.Meter, + ExposedAddress: cfg.Relay.ExposedAddress, + AuthValidator: authenticator, + TLSSupport: tlsSupport, + } + + s.relaySrv, err = createRelayServer(relayCfg, s.stunListeners) + if err != nil { + return err + } + + log.Infof("Relay server created") + + if len(s.stunListeners) > 0 { + s.stunServer = stun.NewServer(s.stunListeners, cfg.Relay.Stun.LogLevel) + } + + return nil +} + +func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Management.Enabled { + return nil + } + + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return fmt.Errorf("failed to create management config: %w", err) + } + + _, portStr, portErr := net.SplitHostPort(cfg.Server.ListenAddress) + if portErr != nil { + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + if err := ApplyEmbeddedIdPConfig(ctx, mgmtConfig, mgmtPort, false); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to apply embedded IdP config: %w", err) + } + + if err := EnsureEncryptionKey(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to ensure encryption key: %w", err) + } + + LogConfigInfo(mgmtConfig) + + s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management server: %w", err) + } + + // Inject externally-managed AppMetrics so management uses the shared metrics server + appMetrics, err := telemetry.NewAppMetricsWithMeter(ctx, s.metricsServer.Meter) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management app metrics: %w", err) + } + mgmtServer.Inject[telemetry.AppMetrics](s.mgmtSrv, appMetrics) + + log.Infof("Management server created") + return nil +} + +func (s *serverInstances) createSignalServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Signal.Enabled { + return nil + } + + var err error + s.signalSrv, err = signalServer.NewServer(ctx, s.metricsServer.Meter, "signal_") + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create signal server: %w", err) + } + + log.Infof("Signal server created") + return nil +} + +func (s *serverInstances) createHealthcheckServer(cfg *CombinedConfig) error { + hCfg := healthcheck.Config{ + ListenAddress: cfg.Server.HealthcheckAddress, + ServiceChecker: s.relaySrv, + } + + var err error + s.healthcheck, err = createHealthCheck(hCfg, s.stunListeners) + return err +} + +// setupServerHooks registers services with management's gRPC server. +func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) { + if servers.mgmtSrv == nil { + return + } + + servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) { + grpcSrv := s.GRPCServer() + + if servers.signalSrv != nil { + proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv) + log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress) + } + + s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg)) + if servers.relaySrv != nil { + log.Infof("Relay WebSocket handler added (path: /relay)") + } + }) +} + +func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) { + if srv != nil { + instanceURL := srv.InstanceURL() + log.Infof("Relay server instance URL: %s", instanceURL.String()) + log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)") + } + + wg.Add(1) + go func() { + defer wg.Done() + log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint) + if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("failed to start metrics server: %v", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("failed to start healthcheck server: %v", err) + } + }() + + if stunServer != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := stunServer.Listen(); err != nil { + if errors.Is(err, stun.ErrServerClosed) { + return + } + log.Errorf("STUN server error: %v", err) + } + }() + } +} + +func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv *mgmtServer.BaseServer, metricsServer *sharedMetrics.Metrics) error { + var errs error + + if err := httpHealthcheck.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close healthcheck server: %w", err)) + } + + if stunServer != nil { + if err := stunServer.Shutdown(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close STUN server: %w", err)) + } + } + + if srv != nil { + if err := srv.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close relay server: %w", err)) + } + } + + if mgmtSrv != nil { + log.Infof("shutting down management and signal servers") + if err := mgmtSrv.Stop(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close management server: %w", err)) + } + } + + if metricsServer != nil { + log.Infof("shutting down metrics server") + if err := metricsServer.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close metrics server: %w", err)) + } + } + + return errs +} + +func createHealthCheck(hCfg healthcheck.Config, stunListeners []*net.UDPConn) (*healthcheck.Server, error) { + httpHealthcheck, err := healthcheck.NewServer(hCfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create healthcheck server: %w", err) + } + return httpHealthcheck, nil +} + +func createRelayServer(cfg relayServer.Config, stunListeners []*net.UDPConn) (*relayServer.Server, error) { + srv, err := relayServer.NewServer(cfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create relay server: %w", err) + } + return srv, nil +} + +func cleanupSTUNListeners(stunListeners []*net.UDPConn) { + for _, l := range stunListeners { + _ = l.Close() + } +} + +func createSTUNListeners(cfg *CombinedConfig) ([]*net.UDPConn, error) { + var stunListeners []*net.UDPConn + if cfg.Relay.Stun.Enabled { + for _, port := range cfg.Relay.Stun.Ports { + listener, err := net.ListenUDP("udp", &net.UDPAddr{Port: port}) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create STUN listener on port %d: %w", port, err) + } + stunListeners = append(stunListeners, listener) + log.Infof("STUN server listening on UDP port %d", port) + } + } + return stunListeners, nil +} + +func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { + tlsCfg := cfg.Server.TLS + + if tlsCfg.LetsEncrypt.AWSRoute53 { + log.Debugf("using Let's Encrypt DNS resolver with Route 53 support") + r53 := encryption.Route53TLS{ + DataDir: tlsCfg.LetsEncrypt.DataDir, + Email: tlsCfg.LetsEncrypt.Email, + Domains: tlsCfg.LetsEncrypt.Domains, + } + tc, err := r53.GetCertificate() + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + if cfg.HasLetsEncrypt() { + log.Infof("setting up TLS with Let's Encrypt") + certManager, err := encryption.CreateCertManager(tlsCfg.LetsEncrypt.DataDir, tlsCfg.LetsEncrypt.Domains...) + if err != nil { + return nil, false, fmt.Errorf("failed creating LetsEncrypt cert manager: %w", err) + } + return certManager.TLSConfig(), true, nil + } + + if cfg.HasTLSCert() { + log.Debugf("using file based TLS config") + tc, err := encryption.LoadTLSConfig(tlsCfg.CertFile, tlsCfg.KeyFile) + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + return nil, false, nil +} + +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) { + mgmt := cfg.Management + + dnsDomain := mgmt.DnsDomain + singleAccModeDomain := dnsDomain + + // Extract port from listen address + _, portStr, err := net.SplitHostPort(cfg.Server.ListenAddress) + if err != nil { + // If no port specified, assume default + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + mgmtSrv := mgmtServer.NewServer( + mgmtConfig, + dnsDomain, + singleAccModeDomain, + mgmtPort, + cfg.Server.MetricsPort, + mgmt.DisableAnonymousMetrics, + mgmt.DisableGeoliteUpdate, + // Always enable user deletion from IDP in combined server (embedded IdP is always enabled) + true, + ) + + return mgmtSrv, nil +} + +// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic +func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler { + wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter)) + + var relayAcceptFn func(conn net.Conn) + if relaySrv != nil { + relayAcceptFn = relaySrv.RelayAccept() + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // Native gRPC traffic (HTTP/2 with gRPC content-type) + case r.ProtoMajor == 2 && (strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") || + strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc+proto")): + grpcServer.ServeHTTP(w, r) + + // WebSocket proxy for Management gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent: + wsProxy.Handler().ServeHTTP(w, r) + + // WebSocket proxy for Signal gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.SignalComponent: + if cfg.Signal.Enabled { + wsProxy.Handler().ServeHTTP(w, r) + } else { + http.Error(w, "Signal service not enabled", http.StatusNotFound) + } + + // Relay WebSocket + case r.URL.Path == "/relay": + if relayAcceptFn != nil { + handleRelayWebSocket(w, r, relayAcceptFn, cfg) + } else { + http.Error(w, "Relay service not enabled", http.StatusNotFound) + } + + // Management HTTP API (default) + default: + httpHandler.ServeHTTP(w, r) + } + }) +} + +// handleRelayWebSocket handles incoming WebSocket connections for the relay service +func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func(conn net.Conn), cfg *CombinedConfig) { + acceptOptions := &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + } + + wsConn, err := websocket.Accept(w, r, acceptOptions) + if err != nil { + log.Errorf("failed to accept relay ws connection: %s", err) + return + } + + connRemoteAddr := r.RemoteAddr + if r.Header.Get("X-Real-Ip") != "" && r.Header.Get("X-Real-Port") != "" { + connRemoteAddr = net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port")) + } + + rAddr, err := net.ResolveTCPAddr("tcp", connRemoteAddr) + if err != nil { + _ = wsConn.Close(websocket.StatusInternalError, "internal error") + return + } + + lAddr, err := net.ResolveTCPAddr("tcp", cfg.Server.ListenAddress) + if err != nil { + _ = wsConn.Close(websocket.StatusInternalError, "internal error") + return + } + + log.Debugf("Relay WS client connected from: %s", rAddr) + + conn := ws.NewConn(wsConn, lAddr, rAddr) + acceptFn(conn) +} + +// logConfig prints all configuration parameters for debugging +func logConfig(cfg *CombinedConfig) { + log.Info("=== Configuration ===") + logServerConfig(cfg) + logComponentsConfig(cfg) + logRelayConfig(cfg) + logManagementConfig(cfg) + log.Info("=== End Configuration ===") +} + +func logServerConfig(cfg *CombinedConfig) { + log.Info("--- Server ---") + log.Infof(" Listen address: %s", cfg.Server.ListenAddress) + log.Infof(" Exposed address: %s", cfg.Server.ExposedAddress) + log.Infof(" Healthcheck address: %s", cfg.Server.HealthcheckAddress) + log.Infof(" Metrics port: %d", cfg.Server.MetricsPort) + log.Infof(" Log level: %s", cfg.Server.LogLevel) + log.Infof(" Data dir: %s", cfg.Server.DataDir) + + switch { + case cfg.HasTLSCert(): + log.Infof(" TLS: cert=%s, key=%s", cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile) + case cfg.HasLetsEncrypt(): + log.Infof(" TLS: Let's Encrypt (domains=%v)", cfg.Server.TLS.LetsEncrypt.Domains) + default: + log.Info(" TLS: disabled (using reverse proxy)") + } +} + +func logComponentsConfig(cfg *CombinedConfig) { + log.Info("--- Components ---") + log.Infof(" Management: %v (log level: %s)", cfg.Management.Enabled, cfg.Management.LogLevel) + log.Infof(" Signal: %v (log level: %s)", cfg.Signal.Enabled, cfg.Signal.LogLevel) + log.Infof(" Relay: %v (log level: %s)", cfg.Relay.Enabled, cfg.Relay.LogLevel) +} + +func logRelayConfig(cfg *CombinedConfig) { + if !cfg.Relay.Enabled { + return + } + log.Info("--- Relay ---") + log.Infof(" Exposed address: %s", cfg.Relay.ExposedAddress) + log.Infof(" Auth secret: %s...", maskSecret(cfg.Relay.AuthSecret)) + if cfg.Relay.Stun.Enabled { + log.Infof(" STUN ports: %v (log level: %s)", cfg.Relay.Stun.Ports, cfg.Relay.Stun.LogLevel) + } else { + log.Info(" STUN: disabled") + } +} + +func logManagementConfig(cfg *CombinedConfig) { + if !cfg.Management.Enabled { + return + } + log.Info("--- Management ---") + log.Infof(" Data dir: %s", cfg.Management.DataDir) + log.Infof(" DNS domain: %s", cfg.Management.DnsDomain) + log.Infof(" Store engine: %s", cfg.Management.Store.Engine) + if cfg.Server.Store.DSN != "" { + log.Infof(" Store DSN: %s", maskDSNPassword(cfg.Server.Store.DSN)) + } + + log.Info(" Auth (embedded IdP):") + log.Infof(" Issuer: %s", cfg.Management.Auth.Issuer) + log.Infof(" Dashboard redirect URIs: %v", cfg.Management.Auth.DashboardRedirectURIs) + log.Infof(" CLI redirect URIs: %v", cfg.Management.Auth.CLIRedirectURIs) + + log.Info(" Client settings:") + log.Infof(" Signal URI: %s", cfg.Management.SignalURI) + for _, s := range cfg.Management.Stuns { + log.Infof(" STUN: %s", s.URI) + } + if len(cfg.Management.Relays.Addresses) > 0 { + log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) + log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) + } +} + +// logEnvVars logs all NB_ environment variables that are currently set +func logEnvVars() { + log.Info("=== Environment Variables ===") + found := false + for _, env := range os.Environ() { + if strings.HasPrefix(env, "NB_") { + key, _, _ := strings.Cut(env, "=") + value := os.Getenv(key) + if strings.Contains(strings.ToLower(key), "secret") || strings.Contains(strings.ToLower(key), "key") || strings.Contains(strings.ToLower(key), "password") { + value = maskSecret(value) + } + log.Infof(" %s=%s", key, value) + found = true + } + } + if !found { + log.Info(" (none set)") + } + log.Info("=== End Environment Variables ===") +} + +// maskDSNPassword masks the password in a DSN string. +// Handles both key=value format ("password=secret") and URI format ("user:secret@host"). +func maskDSNPassword(dsn string) string { + // Key=value format: "host=localhost user=nb password=secret dbname=nb" + if strings.Contains(dsn, "password=") { + parts := strings.Fields(dsn) + for i, p := range parts { + if strings.HasPrefix(p, "password=") { + parts[i] = "password=****" + } + } + return strings.Join(parts, " ") + } + + // URI format: "user:password@host..." + if atIdx := strings.Index(dsn, "@"); atIdx != -1 { + prefix := dsn[:atIdx] + if colonIdx := strings.Index(prefix, ":"); colonIdx != -1 { + return prefix[:colonIdx+1] + "****" + dsn[atIdx:] + } + } + + return dsn +} + +// maskSecret returns first 4 chars of secret followed by "..." +func maskSecret(secret string) string { + if len(secret) <= 4 { + return "****" + } + return secret[:4] + "..." +} diff --git a/combined/config-simple.yaml.example b/combined/config-simple.yaml.example new file mode 100644 index 000000000..4a90adda8 --- /dev/null +++ b/combined/config-simple.yaml.example @@ -0,0 +1,111 @@ +# NetBird Combined Server Configuration +# Copy this file to config.yaml and customize for your deployment +# +# This is a Management server with optional embedded Signal, Relay, and STUN services. +# By default, all services run locally. You can use external services instead by +# setting the corresponding override fields. +# +# Architecture: +# - Management: Always runs locally (this IS the management server) +# - Signal: Local by default; set 'signalUri' to use external (disables local) +# - Relay: Local by default; set 'relays' to use external (disables local) +# - STUN: Local on port 3478 by default; set 'stuns' to use external instead + +server: + # Main HTTP/gRPC port for all services (Management, Signal, Relay) + listenAddress: ":443" + + # Public address that peers will use to connect to this server + # Used for relay connections and management DNS domain + # Format: protocol://hostname:port (e.g., https://server.mycompany.com:443) + exposedAddress: "https://server.mycompany.com:443" + + # STUN server ports (defaults to [3478] if not specified; set 'stuns' to use external) + # stunPorts: + # - 3478 + + # Metrics endpoint port + metricsPort: 9090 + + # Healthcheck endpoint address + healthcheckAddress: ":9000" + + # Logging configuration + logLevel: "info" # Default log level for all components: panic, fatal, error, warn, info, debug, trace + logFile: "console" # "console" or path to log file + + # TLS configuration (optional) + tls: + certFile: "" + keyFile: "" + letsencrypt: + enabled: false + dataDir: "" + domains: [] + email: "" + awsRoute53: false + + # Shared secret for relay authentication (required when running local relay) + authSecret: "your-secret-key-here" + + # Data directory for all services + dataDir: "/var/lib/netbird/" + + # ============================================================================ + # External Service Overrides (optional) + # Use these to point to external Signal, Relay, or STUN servers instead of + # running them locally. When set, the corresponding local service is disabled. + # ============================================================================ + + # External STUN servers - disables local STUN server + # stuns: + # - uri: "stun:stun.example.com:3478" + # - uri: "stun:stun.example.com:3479" + + # External relay servers - disables local relay server + # relays: + # addresses: + # - "rels://relay.example.com:443" + # credentialsTTL: "12h" + # secret: "relay-shared-secret" + + # External signal server - disables local signal server + # signalUri: "https://signal.example.com:443" + + # ============================================================================ + # Management Settings + # ============================================================================ + + # Metrics and updates + disableAnonymousMetrics: false + disableGeoliteUpdate: false + + # Embedded authentication/identity provider (Dex) configuration (always enabled) + auth: + # OIDC issuer URL - must be publicly accessible + issuer: "https://server.mycompany.com/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + # OAuth2 redirect URIs for dashboard + dashboardRedirectURIs: + - "https://app.netbird.io/nb-auth" + - "https://app.netbird.io/nb-silent-auth" + # OAuth2 redirect URIs for CLI + cliRedirectURIs: + - "http://localhost:53000/" + # Optional initial admin user + # owner: + # email: "admin@example.com" + # password: "initial-password" + + # Store configuration + store: + engine: "sqlite" # sqlite, postgres, or mysql + dsn: "" # Connection string for postgres or mysql + encryptionKey: "" + + # Reverse proxy settings (optional) + # reverseProxy: + # trustedHTTPProxies: [] + # trustedHTTPProxiesCount: 0 + # trustedPeers: [] \ No newline at end of file diff --git a/combined/config.yaml.example b/combined/config.yaml.example new file mode 100644 index 000000000..6cb10e04d --- /dev/null +++ b/combined/config.yaml.example @@ -0,0 +1,115 @@ +# Simplified Combined NetBird Server Configuration +# Copy this file to config.yaml and customize for your deployment + +# Server-wide settings +server: + # Main HTTP/gRPC port for all services (Management, Signal, Relay) + listenAddress: ":443" + + # Metrics endpoint port + metricsPort: 9090 + + # Healthcheck endpoint address + healthcheckAddress: ":9000" + + # Logging configuration + logLevel: "info" # panic, fatal, error, warn, info, debug, trace + logFile: "console" # "console" or path to log file + + # TLS configuration (optional) + tls: + certFile: "" + keyFile: "" + letsencrypt: + enabled: false + dataDir: "" + domains: [] + email: "" + awsRoute53: false + +# Relay service configuration +relay: + # Enable/disable the relay service + enabled: true + + # Public address that peers will use to connect to this relay + # Format: hostname:port or ip:port + exposedAddress: "relay.example.com:443" + + # Shared secret for relay authentication (required when enabled) + authSecret: "your-secret-key-here" + + # Log level for relay (reserved for future use, currently uses global log level) + logLevel: "info" + + # Embedded STUN server (optional) + stun: + enabled: false + ports: [3478] + logLevel: "info" + +# Signal service configuration +signal: + # Enable/disable the signal service + enabled: true + + # Log level for signal (reserved for future use, currently uses global log level) + logLevel: "info" + +# Management service configuration +management: + # Enable/disable the management service + enabled: true + + # Data directory for management service + dataDir: "/var/lib/netbird/" + + # DNS domain for the management server + dnsDomain: "" + + # Metrics and updates + disableAnonymousMetrics: false + disableGeoliteUpdate: false + + auth: + # OIDC issuer URL - must be publicly accessible + issuer: "https://management.example.com/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + # OAuth2 redirect URIs for dashboard + dashboardRedirectURIs: + - "https://app.example.com/nb-auth" + - "https://app.example.com/nb-silent-auth" + # OAuth2 redirect URIs for CLI + cliRedirectURIs: + - "http://localhost:53000/" + # Optional initial admin user + # owner: + # email: "admin@example.com" + # password: "initial-password" + + # External STUN servers (for client config) + stuns: [] + # - uri: "stun:stun.example.com:3478" + + # External relay servers (for client config) + relays: + addresses: [] + # - "rels://relay.example.com:443" + credentialsTTL: "12h" + secret: "" + + # External signal server URI (for client config) + signalUri: "" + + # Store configuration + store: + engine: "sqlite" # sqlite, postgres, or mysql + dsn: "" # Connection string for postgres or mysql + encryptionKey: "" + + # Reverse proxy settings + reverseProxy: + trustedHTTPProxies: [] + trustedHTTPProxiesCount: 0 + trustedPeers: [] diff --git a/combined/main.go b/combined/main.go new file mode 100644 index 000000000..6740ac93e --- /dev/null +++ b/combined/main.go @@ -0,0 +1,13 @@ +package main + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/combined/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + log.Fatalf("failed to execute command: %v", err) + } +} diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 25599997c..fd50c4871 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -85,8 +85,8 @@ read_nb_domain() { read_reverse_proxy_type() { echo "" > /dev/stderr echo "Which reverse proxy will you use?" > /dev/stderr - echo " [0] Built-in Caddy (recommended - automatic TLS)" > /dev/stderr - echo " [1] Traefik (labels added to containers)" > /dev/stderr + echo " [0] Traefik (recommended - automatic TLS, included in Docker Compose)" > /dev/stderr + echo " [1] Existing Traefik (labels for external Traefik instance)" > /dev/stderr echo " [2] Nginx (generates config template)" > /dev/stderr echo " [3] Nginx Proxy Manager (generates config + instructions)" > /dev/stderr echo " [4] External Caddy (generates Caddyfile snippet)" > /dev/stderr @@ -182,20 +182,21 @@ get_upstream_host() { return 0 } -wait_management() { +wait_management_proxy() { + local proxy_container="${1:-traefik}" set +e - echo -n "Waiting for Management server to become ready" + echo -n "Waiting for NetBird server to become ready" counter=1 while true; do - # Check the embedded IdP endpoint + # Check the embedded IdP endpoint through the reverse proxy if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2/.well-known/openid-configuration" 2>/dev/null; then break fi if [[ $counter -eq 60 ]]; then echo "" echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 caddy - $DOCKER_COMPOSE_COMMAND logs --tail=20 management + $DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container" + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server fi echo -n " ." sleep 2 @@ -209,7 +210,7 @@ wait_management() { wait_management_direct() { set +e local upstream_host=$(get_upstream_host) - echo -n "Waiting for Management server to become ready" + echo -n "Waiting for NetBird server to become ready" counter=1 while true; do # Check the embedded IdP endpoint directly (no reverse proxy) @@ -219,7 +220,7 @@ wait_management_direct() { if [[ $counter -eq 60 ]]; then echo "" echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 management + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server fi echo -n " ." sleep 2 @@ -235,7 +236,6 @@ wait_management_direct() { ############################################ initialize_default_values() { - CADDY_SECURE_DOMAIN="" NETBIRD_PORT=80 NETBIRD_HTTP_PROTOCOL="http" NETBIRD_RELAY_PROTO="rel" @@ -245,11 +245,9 @@ initialize_default_values() { NETBIRD_STUN_PORT=3478 # Docker images - CADDY_IMAGE="caddy" DASHBOARD_IMAGE="netbirdio/dashboard:latest" - SIGNAL_IMAGE="netbirdio/signal:latest" - RELAY_IMAGE="netbirdio/relay:latest" - MANAGEMENT_IMAGE="netbirdio/management:latest" + # Combined server replaces separate signal, relay, and management containers + NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" # Reverse proxy configuration REVERSE_PROXY_TYPE="0" @@ -257,10 +255,7 @@ initialize_default_values() { TRAEFIK_ENTRYPOINT="websecure" TRAEFIK_CERTRESOLVER="" DASHBOARD_HOST_PORT="8080" - MANAGEMENT_HOST_PORT="8081" - SIGNAL_HOST_PORT="8083" - SIGNAL_GRPC_PORT="10000" - RELAY_HOST_PORT="8084" + MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) BIND_LOCALHOST_ONLY="true" EXTERNAL_PROXY_NETWORK="" return 0 @@ -275,7 +270,6 @@ configure_domain() { NETBIRD_DOMAIN=$(get_main_ip_address) else NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" NETBIRD_HTTP_PROTOCOL="https" NETBIRD_RELAY_PROTO="rels" fi @@ -286,7 +280,7 @@ configure_reverse_proxy() { # Prompt for reverse proxy type REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) - # Handle Traefik-specific prompts + # Handle Traefik-specific prompts (only for external Traefik) if [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then TRAEFIK_EXTERNAL_NETWORK=$(read_traefik_network) TRAEFIK_ENTRYPOINT=$(read_traefik_entrypoint) @@ -309,11 +303,11 @@ configure_reverse_proxy() { } check_existing_installation() { - if [[ -f management.json ]]; then + if [[ -f config.yaml ]]; then echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." echo "You can use the following commands:" echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile dashboard.env management.json relay.env nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt" + echo " rm -f docker-compose.yml dashboard.env config.yaml nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt" echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." exit 1 fi @@ -326,8 +320,7 @@ generate_configuration_files() { # Render docker-compose and proxy config based on selection case "$REVERSE_PROXY_TYPE" in 0) - render_docker_compose > docker-compose.yml - render_caddyfile > Caddyfile + render_docker_compose_traefik_builtin > docker-compose.yml ;; 1) render_docker_compose_traefik > docker-compose.yml @@ -355,27 +348,26 @@ generate_configuration_files() { # Common files for all configurations render_dashboard_env > dashboard.env - render_management_json > management.json - render_relay_env > relay.env + render_combined_yaml > config.yaml return 0 } start_services_and_show_instructions() { - # For built-in Caddy and Traefik, start containers immediately + # For built-in Traefik, start containers immediately # For NPM, start containers first (NPM needs services running to create proxy) # For other external proxies, show instructions first and wait for user confirmation if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then - # Built-in Caddy - handles everything automatically + # Built-in Traefik - handles everything automatically (TLS via Let's Encrypt) echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d sleep 3 - wait_management + wait_management_proxy traefik echo -e "$MSG_DONE" print_post_setup_instructions elif [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then - # Traefik - start containers first, then show instructions + # External Traefik - start containers, then show instructions # Traefik discovers services via Docker labels, so containers must be running echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d @@ -441,73 +433,136 @@ init_environment() { # Configuration File Renderers ############################################ -render_caddyfile() { +render_docker_compose_traefik_builtin() { cat < ${upstream_host}:${RELAY_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" + echo " WebSocket (relay, signal, management WS proxy):" + echo " /relay*, /ws-proxy/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " (HTTP with WebSocket upgrade, extended timeout)" echo "" - echo " /ws-proxy/signal* -> ${upstream_host}:${SIGNAL_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" - echo "" - echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${SIGNAL_GRPC_PORT}" + echo " Native gRPC (signal + management):" + echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" echo " (gRPC/h2c - plaintext HTTP/2)" echo "" - echo " /api/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP)" + echo " HTTP (API + embedded IdP):" + echo " /api/*, /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" echo "" - echo " /ws-proxy/management* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" - echo "" - echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (gRPC/h2c - plaintext HTTP/2)" - echo "" - echo " /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP - embedded IdP)" - echo "" - echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}" - echo " (HTTP - catch-all for dashboard)" + echo " Dashboard (catch-all):" + echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}" echo "" echo "IMPORTANT: gRPC routes require HTTP/2 (h2c) upstream support." - echo "Long-running connections need extended timeouts (recommend 1 day)." + echo "WebSocket and gRPC connections need extended timeouts (recommend 1 day)." return 0 } print_post_setup_instructions() { case "$REVERSE_PROXY_TYPE" in 0) - print_caddy_instructions + print_builtin_traefik_instructions ;; 1) print_traefik_instructions diff --git a/management/cmd/management.go b/management/cmd/management.go index 511168823..b064524d8 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -55,7 +55,7 @@ var ( // detect whether user specified a port userPort := cmd.Flag("port").Changed - config, err = loadMgmtConfig(ctx, nbconfig.MgmtConfigPath) + config, err = LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) if err != nil { return fmt.Errorf("failed reading provided config file: %s: %v", nbconfig.MgmtConfigPath, err) } @@ -133,35 +133,35 @@ var ( } ) -func loadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { +func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { loadedConfig := &nbconfig.Config{} if _, err := util.ReadJsonWithEnvSub(mgmtConfigPath, loadedConfig); err != nil { return nil, err } - applyCommandLineOverrides(loadedConfig) + ApplyCommandLineOverrides(loadedConfig) // Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled - err := applyEmbeddedIdPConfig(ctx, loadedConfig) + err := ApplyEmbeddedIdPConfig(ctx, loadedConfig) if err != nil { return nil, err } - if err := applyOIDCConfig(ctx, loadedConfig); err != nil { + if err := ApplyOIDCConfig(ctx, loadedConfig); err != nil { return nil, err } - logConfigInfo(loadedConfig) + LogConfigInfo(loadedConfig) - if err := ensureEncryptionKey(ctx, mgmtConfigPath, loadedConfig); err != nil { + if err := EnsureEncryptionKey(ctx, mgmtConfigPath, loadedConfig); err != nil { return nil, err } return loadedConfig, nil } -// applyCommandLineOverrides applies command-line flag overrides to the config -func applyCommandLineOverrides(cfg *nbconfig.Config) { +// ApplyCommandLineOverrides applies command-line flag overrides to the config +func ApplyCommandLineOverrides(cfg *nbconfig.Config) { if mgmtLetsencryptDomain != "" { cfg.HttpConfig.LetsEncryptDomain = mgmtLetsencryptDomain } @@ -174,9 +174,9 @@ func applyCommandLineOverrides(cfg *nbconfig.Config) { } } -// applyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled. +// ApplyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled. // This allows users to only specify EmbeddedIdP config without duplicating values in HttpConfig. -func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { return nil } @@ -222,8 +222,8 @@ func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { return nil } -// applyOIDCConfig fetches and applies OIDC configuration if endpoint is specified -func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { +// ApplyOIDCConfig fetches and applies OIDC configuration if endpoint is specified +func ApplyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { oidcEndpoint := cfg.HttpConfig.OIDCConfigEndpoint if oidcEndpoint == "" { return nil @@ -249,16 +249,16 @@ func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { oidcConfig.JwksURI, cfg.HttpConfig.AuthKeysLocation) cfg.HttpConfig.AuthKeysLocation = oidcConfig.JwksURI - if err := applyDeviceAuthFlowConfig(ctx, cfg, &oidcConfig, oidcEndpoint); err != nil { + if err := ApplyDeviceAuthFlowConfig(ctx, cfg, &oidcConfig, oidcEndpoint); err != nil { return err } - applyPKCEFlowConfig(ctx, cfg, &oidcConfig) + ApplyPKCEFlowConfig(ctx, cfg, &oidcConfig) return nil } -// applyDeviceAuthFlowConfig applies OIDC config to DeviceAuthorizationFlow if enabled -func applyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse, oidcEndpoint string) error { +// ApplyDeviceAuthFlowConfig applies OIDC config to DeviceAuthorizationFlow if enabled +func ApplyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse, oidcEndpoint string) error { if cfg.DeviceAuthorizationFlow == nil || strings.ToLower(cfg.DeviceAuthorizationFlow.Provider) == string(nbconfig.NONE) { return nil } @@ -285,8 +285,8 @@ func applyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcCo return nil } -// applyPKCEFlowConfig applies OIDC config to PKCEAuthorizationFlow if configured -func applyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse) { +// ApplyPKCEFlowConfig applies OIDC config to PKCEAuthorizationFlow if configured +func ApplyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse) { if cfg.PKCEAuthorizationFlow == nil { return } @@ -299,8 +299,8 @@ func applyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig * cfg.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint = oidcConfig.AuthorizationEndpoint } -// logConfigInfo logs informational messages about the loaded configuration -func logConfigInfo(cfg *nbconfig.Config) { +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { if cfg.EmbeddedIdP != nil { log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) } @@ -309,8 +309,8 @@ func logConfigInfo(cfg *nbconfig.Config) { } } -// ensureEncryptionKey generates and saves a DataStoreEncryptionKey if not set -func ensureEncryptionKey(ctx context.Context, configPath string, cfg *nbconfig.Config) error { +// EnsureEncryptionKey generates and saves a DataStoreEncryptionKey if not set +func EnsureEncryptionKey(ctx context.Context, configPath string, cfg *nbconfig.Config) error { if cfg.DataStoreEncryptionKey != "" { return nil } diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index 244d86254..f0c89dd3f 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -30,7 +30,7 @@ func Test_loadMgmtConfig(t *testing.T) { t.Fatalf("failed to create config: %s", err) } - cfg, err := loadMgmtConfig(context.Background(), tmpFile) + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) if err != nil { t.Fatalf("failed to load management config: %s", err) } diff --git a/management/internals/server/server.go b/management/internals/server/server.go index cd8d8e8fb..0f985c4ed 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -11,7 +11,6 @@ import ( "time" "github.com/google/uuid" - "github.com/netbirdio/netbird/management/server/idp" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" @@ -19,6 +18,8 @@ import ( "golang.org/x/net/http2/h2c" "google.golang.org/grpc" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/encryption" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/metrics" @@ -138,6 +139,14 @@ func (s *BaseServer) Start(ctx context.Context) error { go metricsWorker.Run(srvCtx) } + // Run afterInit hooks before starting any servers + // This allows registering additional gRPC services (e.g., Signal) before Serve() is called + for _, fn := range s.afterInit { + if fn != nil { + fn(s) + } + } + var compatListener net.Listener if s.mgmtPort != ManagementLegacyPort { // The Management gRPC server was running on port 33073 previously. Old agents that are already connected to it @@ -178,12 +187,6 @@ func (s *BaseServer) Start(ctx context.Context) error { } } - for _, fn := range s.afterInit { - if fn != nil { - fn(s) - } - } - log.WithContext(ctx).Infof("management server version %s", version.NetbirdVersion()) log.WithContext(ctx).Infof("running HTTP server and gRPC server on the same port: %s", s.listener.Addr().String()) s.serveGRPCWithHTTP(ctx, s.listener, rootHandler, tlsEnabled) @@ -255,7 +258,23 @@ func (s *BaseServer) SetContainer(key string, container any) { log.Tracef("container with key %s set successfully", key) } +// SetHandlerFunc allows overriding the default HTTP handler function. +// This is useful for multiplexing additional services on the same port. +func (s *BaseServer) SetHandlerFunc(handler http.Handler) { + s.container["customHandler"] = handler + log.Tracef("custom handler set successfully") +} + func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, meter metric.Meter) http.Handler { + // Check if a custom handler was set (for multiplexing additional services) + if customHandler, ok := s.GetContainer("customHandler"); ok { + if handler, ok := customHandler.(http.Handler); ok { + log.Tracef("using custom handler") + return handler + } + } + + // Use default handler wsProxy := wsproxyserver.New(gRPCHandler, wsproxyserver.WithOTelMeter(meter)) return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7f48f510e..f9ad1987c 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -2643,7 +2643,7 @@ func getGormConfig() *gorm.Config { // newPostgresStore initializes a new Postgres store. func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", postgresDsnEnv) } @@ -2652,7 +2652,7 @@ func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMig // newMysqlStore initializes a new MySQL store. func newMysqlStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) } diff --git a/management/server/store/store.go b/management/server/store/store.go index be0d29768..3928ce3f0 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -243,10 +243,20 @@ type Store interface { } const ( - postgresDsnEnv = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" - mysqlDsnEnv = "NETBIRD_STORE_ENGINE_MYSQL_DSN" + postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" + postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" + mysqlDsnEnv = "NB_STORE_ENGINE_MYSQL_DSN" + mysqlDsnEnvLegacy = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) +// lookupDSNEnv checks the NB_ env var first, then falls back to the legacy NETBIRD_ env var. +func lookupDSNEnv(nbKey, legacyKey string) (string, bool) { + if v, ok := os.LookupEnv(nbKey); ok { + return v, true + } + return os.LookupEnv(legacyKey) +} + var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine, types.MysqlStoreEngine} func getStoreEngineFromEnv() types.Engine { @@ -531,7 +541,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine) } func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreatePostgresTestContainer() @@ -569,7 +579,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng } func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreateMysqlTestContainer() diff --git a/management/server/telemetry/app_metrics.go b/management/server/telemetry/app_metrics.go index 988f91779..1fd78bc3a 100644 --- a/management/server/telemetry/app_metrics.go +++ b/management/server/telemetry/app_metrics.go @@ -122,6 +122,7 @@ type defaultAppMetrics struct { Meter metric2.Meter listener net.Listener ctx context.Context + externallyManaged bool idpMetrics *IDPMetrics httpMiddleware *HTTPMiddleware grpcMetrics *GRPCMetrics @@ -171,6 +172,9 @@ func (appMetrics *defaultAppMetrics) Close() error { // Expose metrics on a given port and endpoint. If endpoint is empty a defaultEndpoint one will be used. // Exposes metrics in the Prometheus format https://prometheus.io/ func (appMetrics *defaultAppMetrics) Expose(ctx context.Context, port int, endpoint string) error { + if appMetrics.externallyManaged { + return nil + } if endpoint == "" { endpoint = defaultEndpoint } @@ -252,3 +256,49 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { accountManagerMetrics: accountManagerMetrics, }, nil } + +// NewAppMetricsWithMeter creates AppMetrics using an externally provided meter. +// The caller is responsible for exposing metrics via HTTP. Expose() and Close() are no-ops. +func NewAppMetricsWithMeter(ctx context.Context, meter metric2.Meter) (AppMetrics, error) { + idpMetrics, err := NewIDPMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize IDP metrics: %w", err) + } + + middleware, err := NewMetricsMiddleware(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize HTTP middleware metrics: %w", err) + } + + grpcMetrics, err := NewGRPCMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize gRPC metrics: %w", err) + } + + storeMetrics, err := NewStoreMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize store metrics: %w", err) + } + + updateChannelMetrics, err := NewUpdateChannelMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize update channel metrics: %w", err) + } + + accountManagerMetrics, err := NewAccountManagerMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) + } + + return &defaultAppMetrics{ + Meter: meter, + ctx: ctx, + externallyManaged: true, + idpMetrics: idpMetrics, + httpMiddleware: middleware, + grpcMetrics: grpcMetrics, + storeMetrics: storeMetrics, + updateChannelMetrics: updateChannelMetrics, + accountManagerMetrics: accountManagerMetrics, + }, nil +} diff --git a/relay/cmd/root.go b/relay/cmd/root.go index 20c565c3d..b1949ca11 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -21,8 +21,8 @@ import ( "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/relay/healthcheck" "github.com/netbirdio/netbird/relay/server" + "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" - "github.com/netbirdio/netbird/signal/metrics" "github.com/netbirdio/netbird/stun" "github.com/netbirdio/netbird/util" ) diff --git a/relay/server/server.go b/relay/server/server.go index 8e4333064..a0f7eb73c 100644 --- a/relay/server/server.go +++ b/relay/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" "crypto/tls" + "net" "net/url" "sync" @@ -134,3 +135,10 @@ func (r *Server) ListenerProtocols() []protocol.Protocol { func (r *Server) InstanceURL() url.URL { return r.relay.InstanceURL() } + +// RelayAccept returns the relay's Accept function for handling incoming connections. +// This allows external HTTP handlers to route connections to the relay without +// starting the relay's own listeners. +func (r *Server) RelayAccept() func(conn net.Conn) { + return r.relay.Accept +} diff --git a/signal/metrics/metrics.go b/shared/metrics/metrics.go similarity index 100% rename from signal/metrics/metrics.go rename to shared/metrics/metrics.go diff --git a/signal/cmd/root.go b/signal/cmd/root.go index 7fa75d923..155790482 100644 --- a/signal/cmd/root.go +++ b/signal/cmd/root.go @@ -40,7 +40,6 @@ func Execute() error { func init() { stopCh = make(chan int) defaultLogFile = "/var/log/netbird/signal.log" - defaultSignalSSLDir = "/var/lib/netbird/" if runtime.GOOS == "windows" { defaultLogFile = os.Getenv("PROGRAMDATA") + "\\Netbird\\" + "signal.log" diff --git a/signal/cmd/run.go b/signal/cmd/run.go index d7662a886..681222403 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -18,7 +18,7 @@ import ( "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" - "github.com/netbirdio/netbird/signal/metrics" + "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/shared/signal/proto" @@ -38,13 +38,13 @@ import ( const legacyGRPCPort = 10000 var ( - signalPort int - metricsPort int - signalLetsencryptDomain string - signalSSLDir string - defaultSignalSSLDir string - signalCertFile string - signalCertKey string + signalPort int + metricsPort int + signalLetsencryptDomain string + signalLetsencryptEmail string + signalLetsencryptDataDir string + signalCertFile string + signalCertKey string signalKaep = grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: 5 * time.Second, @@ -216,7 +216,7 @@ func getTLSConfigurations() ([]grpc.ServerOption, *autocert.Manager, *tls.Config } if signalLetsencryptDomain != "" { - certManager, err = encryption.CreateCertManager(signalSSLDir, signalLetsencryptDomain) + certManager, err = encryption.CreateCertManager(signalLetsencryptDataDir, signalLetsencryptDomain) if err != nil { return nil, certManager, nil, err } @@ -326,9 +326,11 @@ func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { func init() { runCmd.PersistentFlags().IntVar(&signalPort, "port", 80, "Server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") runCmd.Flags().IntVar(&metricsPort, "metrics-port", 9090, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") - runCmd.Flags().StringVar(&signalSSLDir, "ssl-dir", defaultSignalSSLDir, "server ssl directory location. *Required only for Let's Encrypt certificates.") - runCmd.Flags().StringVar(&signalLetsencryptDomain, "letsencrypt-domain", "", "a domain to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS") - runCmd.Flags().StringVar(&signalCertFile, "cert-file", "", "Location of your SSL certificate. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") - runCmd.Flags().StringVar(&signalCertKey, "cert-key", "", "Location of your SSL certificate private key. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") + runCmd.PersistentFlags().StringVar(&signalLetsencryptDataDir, "letsencrypt-data-dir", "", "a directory to store Let's Encrypt data. Required if Let's Encrypt is enabled.") + runCmd.PersistentFlags().StringVar(&signalLetsencryptDataDir, "ssl-dir", "", "server ssl directory location. *Required only for Let's Encrypt certificates. Deprecated: use --letsencrypt-data-dir") + runCmd.PersistentFlags().StringVar(&signalLetsencryptDomain, "letsencrypt-domain", "", "a domain to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS") + runCmd.PersistentFlags().StringVar(&signalLetsencryptEmail, "letsencrypt-email", "", "email address to use for Let's Encrypt certificate registration") + runCmd.PersistentFlags().StringVar(&signalCertFile, "cert-file", "", "Location of your SSL certificate. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") + runCmd.PersistentFlags().StringVar(&signalCertKey, "cert-key", "", "Location of your SSL certificate private key. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") setFlagsFromEnvVars(runCmd) } diff --git a/signal/metrics/app.go b/signal/metrics/app.go index e3b1c67cd..759b51913 100644 --- a/signal/metrics/app.go +++ b/signal/metrics/app.go @@ -24,15 +24,19 @@ type AppMetrics struct { MessageSize metric.Int64Histogram } -func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { - activePeers, err := meter.Int64UpDownCounter("active_peers", +func NewAppMetrics(meter metric.Meter, prefix ...string) (*AppMetrics, error) { + p := "" + if len(prefix) > 0 { + p = prefix[0] + } + activePeers, err := meter.Int64UpDownCounter(p+"active_peers", metric.WithDescription("Number of active connected peers"), ) if err != nil { return nil, err } - peerConnectionDuration, err := meter.Int64Histogram("peer_connection_duration_seconds", + peerConnectionDuration, err := meter.Int64Histogram(p+"peer_connection_duration_seconds", metric.WithExplicitBucketBoundaries(getPeerConnectionDurationBucketBoundaries()...), metric.WithDescription("Duration of how long a peer was connected"), ) @@ -40,28 +44,28 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { return nil, err } - registrations, err := meter.Int64Counter("registrations_total", + registrations, err := meter.Int64Counter(p+"registrations_total", metric.WithDescription("Total number of peer registrations"), ) if err != nil { return nil, err } - deregistrations, err := meter.Int64Counter("deregistrations_total", + deregistrations, err := meter.Int64Counter(p+"deregistrations_total", metric.WithDescription("Total number of peer deregistrations"), ) if err != nil { return nil, err } - registrationFailures, err := meter.Int64Counter("registration_failures_total", + registrationFailures, err := meter.Int64Counter(p+"registration_failures_total", metric.WithDescription("Total number of peer registration failures"), ) if err != nil { return nil, err } - registrationDelay, err := meter.Float64Histogram("registration_delay_milliseconds", + registrationDelay, err := meter.Float64Histogram(p+"registration_delay_milliseconds", metric.WithExplicitBucketBoundaries(getStandardBucketBoundaries()...), metric.WithDescription("Duration of how long it takes to register a peer"), ) @@ -69,7 +73,7 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { return nil, err } - getRegistrationDelay, err := meter.Float64Histogram("get_registration_delay_milliseconds", + getRegistrationDelay, err := meter.Float64Histogram(p+"get_registration_delay_milliseconds", metric.WithExplicitBucketBoundaries(getStandardBucketBoundaries()...), metric.WithDescription("Duration of how long it takes to load a connection from the registry"), ) @@ -77,21 +81,21 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { return nil, err } - messagesForwarded, err := meter.Int64Counter("messages_forwarded_total", + messagesForwarded, err := meter.Int64Counter(p+"messages_forwarded_total", metric.WithDescription("Total number of messages forwarded to peers"), ) if err != nil { return nil, err } - messageForwardFailures, err := meter.Int64Counter("message_forward_failures_total", + messageForwardFailures, err := meter.Int64Counter(p+"message_forward_failures_total", metric.WithDescription("Total number of message forwarding failures"), ) if err != nil { return nil, err } - messageForwardLatency, err := meter.Float64Histogram("message_forward_latency_milliseconds", + messageForwardLatency, err := meter.Float64Histogram(p+"message_forward_latency_milliseconds", metric.WithExplicitBucketBoundaries(getStandardBucketBoundaries()...), metric.WithDescription("Duration of how long it takes to forward a message to a peer"), ) @@ -100,7 +104,7 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { } messageSize, err := meter.Int64Histogram( - "message.size.bytes", + p+"message.size.bytes", metric.WithUnit("bytes"), metric.WithExplicitBucketBoundaries(getMessageSizeBucketBoundaries()...), metric.WithDescription("Records the size of each message sent"), diff --git a/signal/server/signal.go b/signal/server/signal.go index 47f01edae..c46df56d2 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -62,8 +62,8 @@ type Server struct { } // NewServer creates a new Signal server -func NewServer(ctx context.Context, meter metric.Meter) (*Server, error) { - appMetrics, err := metrics.NewAppMetrics(meter) +func NewServer(ctx context.Context, meter metric.Meter, metricsPrefix ...string) (*Server, error) { + appMetrics, err := metrics.NewAppMetrics(meter, metricsPrefix...) if err != nil { return nil, fmt.Errorf("creating app metrics: %v", err) } diff --git a/stun/server.go b/stun/server.go index be5717d48..01558f09c 100644 --- a/stun/server.go +++ b/stun/server.go @@ -48,7 +48,7 @@ func NewServer(conns []*net.UDPConn, logLevel string) *Server { // Use the formatter package to set up formatter, ReportCaller, and context hook formatter.SetTextFormatter(stunLogger) - logger := stunLogger.WithField("component", "stun-server") + logger := stunLogger.WithField("component", "stun") logger.Infof("STUN server log level set to: %s", level.String()) return &Server{