Compare commits

..

2 Commits

Author SHA1 Message Date
riccardom
945f8809ee Rework signal protocol to minimize number of exchanged initial RP 512KB keys 2026-07-16 17:25:34 +02:00
riccardom
e3e8dd8cb0 [client] persist Rosenpass static keypair across restarts
The Rosenpass static keypair was regenerated on every engine start
(rp.GenerateKeyPair in NewManager), so the local ~512KB public key —
and its fingerprint — changed on each client restart.

Persist the keypair to <StateDir>/rosenpass_key.json with 0600
permissions (same protection tier as the WireGuard private key), and
reload it on start so the public key stays stable across restarts. A
missing, corrupt, or version-incompatible file degrades gracefully to
generating a fresh ephemeral keypair (previous behaviour); an empty
StateDir keeps the ephemeral path for callers without a state dir.

This is the foundation for fingerprint-based RP pubkey caching over
signalling (NET-1407): a stable local key lets remote peers keep their
cached copy valid across our restart.
2026-07-16 10:18:18 +02:00
34 changed files with 581 additions and 1883 deletions

View File

@@ -23,7 +23,6 @@ import (
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -32,8 +31,6 @@ const (
dnsResolverAddress = "dns-resolver-address"
enableRosenpassFlag = "enable-rosenpass"
rosenpassPermissiveFlag = "rosenpass-permissive"
enableLocalMetricsFlag = "enable-local-metrics"
localMetricsAddressFlag = "local-metrics-address"
preSharedKeyFlag = "preshared-key"
interfaceNameFlag = "interface-name"
wireguardPortFlag = "wireguard-port"
@@ -82,8 +79,6 @@ var (
updateSettingsDisabled bool
captureEnabled bool
networksDisabled bool
localMetricsEnabled bool
localMetricsAddr string
rootCmd = &cobra.Command{
Use: "netbird",
@@ -217,8 +212,6 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")

View File

@@ -499,13 +499,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
req.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
req.LocalMetricsAddress = &localMetricsAddr
}
return &req
}
@@ -623,14 +616,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
ic.DisableIPv6 = &disableIPv6
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
ic.LocalMetricsEnabled = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
ic.LocalMetricsAddress = &localMetricsAddr
}
return &ic, nil
}
@@ -693,14 +678,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.DisableAutoConnect = &autoConnectDisabled
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
loginRequest.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
loginRequest.LocalMetricsAddress = &localMetricsAddr
}
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
return nil, err

View File

@@ -676,8 +676,6 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))

View File

@@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey, e.config.StateDir)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}
@@ -1809,6 +1809,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
PubKey: e.getRosenpassPubKey(),
Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive,
KeyResolver: e.rosenpassKeyResolver(),
},
ICEConfig: e.createICEConfig(),
}
@@ -1879,6 +1880,8 @@ func (e *Engine) receiveSignalEvents() error {
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
e.applyRosenpassKeyExchange(msg, offerAnswer)
if msg.Body.Type == sProto.Body_OFFER {
conn.OnRemoteOffer(*offerAnswer)
} else {
@@ -2222,6 +2225,34 @@ func (e *Engine) getRosenpassAddr() string {
return ""
}
// rosenpassKeyResolver returns the Rosenpass manager as the offer/answer key
// resolver, or a true nil interface when Rosenpass is disabled (returning the
// typed-nil *Manager would make the interface non-nil and panic on use).
func (e *Engine) rosenpassKeyResolver() peer.RosenpassKeyResolver {
if e.rpManager == nil {
return nil
}
return e.rpManager
}
// applyRosenpassKeyExchange reconciles the fingerprint/cache fields of an incoming
// offer/answer against the Rosenpass manager's cache: it resolves the remote peer's
// full public key (from the message or the cache) into the OfferAnswer, and records
// whether the peer acknowledged holding our key. No-op when Rosenpass is disabled.
func (e *Engine) applyRosenpassKeyExchange(msg *sProto.Message, oa *peer.OfferAnswer) {
if e.rpManager == nil {
return
}
cfg := msg.GetBody().GetRosenpassConfig()
if cfg == nil {
return
}
remoteWgKey := msg.GetKey()
oa.RosenpassPubKey = e.rpManager.ResolveRemotePubKey(remoteWgKey, cfg.GetRosenpassPubKey(), cfg.GetRosenpassPubKeyHash())
e.rpManager.SetRemoteAck(remoteWgKey, cfg.GetAcknowledgedRosenpassPubKeyHash())
}
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
// and updates the status recorder with the latest states.
//

View File

@@ -1,243 +0,0 @@
// Package localmetrics exposes client connection state as a local
// Prometheus /metrics endpoint.
package localmetrics
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
dto "github.com/prometheus/client_model/go"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer"
)
// DefaultListenAddress is used when local metrics are enabled without an explicit address.
const DefaultListenAddress = "127.0.0.1:9191"
const (
shutdownTimeout = 3 * time.Second
readHeaderTimeout = 5 * time.Second
readTimeout = 10 * time.Second
writeTimeout = 30 * time.Second
idleTimeout = time.Minute
)
// statusSource provides the connection state snapshots the collector reads on scrape.
type statusSource interface {
GetPeerStates() []peer.State
GetManagementState() peer.ManagementState
GetSignalState() peer.SignalState
}
// GathererProvider returns the current client metrics gatherer, or nil when
// no engine is running. It is called on every scrape.
type GathererProvider func() prometheus.Gatherer
// Manager runs the local /metrics HTTP endpoint according to the active
// client configuration. Reconcile is safe to call on every config change.
type Manager struct {
status statusSource
clientMetrics GathererProvider
mu sync.Mutex
srv *http.Server
addr string
}
// NewManager creates a manager that serves metrics from status and
// clientMetrics and shuts down when ctx is canceled.
func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
m := &Manager{status: status, clientMetrics: clientMetrics}
go func() {
<-ctx.Done()
m.Stop()
}()
return m
}
// Reconcile starts, stops, or restarts the metrics endpoint to match the
// desired state. An empty addr falls back to DefaultListenAddress.
func (m *Manager) Reconcile(enabled bool, addr string) {
if addr == "" {
addr = DefaultListenAddress
}
warnIfNotLoopback(addr)
m.mu.Lock()
defer m.mu.Unlock()
if !enabled {
m.stop()
return
}
if m.srv != nil && m.addr == addr {
return
}
m.stop()
registry := prometheus.NewRegistry()
registry.MustRegister(newCollector(m.status))
gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
if m.clientMetrics == nil {
return nil, nil
}
g := m.clientMetrics()
if g == nil {
return nil, nil
}
return g.Gather()
})}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
m.srv = srv
m.addr = addr
log.Infof("serving local metrics on http://%s/metrics", addr)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("failed to serve local metrics on %s: %v", addr, err)
}
}()
}
// Stop shuts down the metrics endpoint if it is running.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
m.stop()
}
// stop shuts down the running server. Callers must hold m.mu.
func (m *Manager) stop() {
if m.srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := m.srv.Shutdown(ctx); err != nil {
log.Debugf("failed to shut down local metrics server: %v", err)
}
m.srv = nil
m.addr = ""
}
// collector converts status recorder snapshots into Prometheus metrics at scrape time.
type collector struct {
status statusSource
managementConnected *prometheus.Desc
signalConnected *prometheus.Desc
peersTotal *prometheus.Desc
peersConnected *prometheus.Desc
peerLatency *prometheus.Desc
}
func newCollector(status statusSource) *collector {
return &collector{
status: status,
managementConnected: prometheus.NewDesc(
"netbird_management_connected",
"Whether the client is connected to the management service (1 connected, 0 disconnected).",
nil, nil,
),
signalConnected: prometheus.NewDesc(
"netbird_signal_connected",
"Whether the client is connected to the signal service (1 connected, 0 disconnected).",
nil, nil,
),
peersTotal: prometheus.NewDesc(
"netbird_peers",
"Number of peers known to this client.",
nil, nil,
),
peersConnected: prometheus.NewDesc(
"netbird_peers_connected",
"Number of connected peers by connection type.",
[]string{"connection_type"}, nil,
),
peerLatency: prometheus.NewDesc(
"netbird_peer_latency_seconds",
"Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
[]string{"peer"}, nil,
),
}
}
// Describe implements prometheus.Collector.
func (c *collector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.managementConnected
ch <- c.signalConnected
ch <- c.peersTotal
ch <- c.peersConnected
ch <- c.peerLatency
}
// Collect implements prometheus.Collector.
func (c *collector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
peers := c.status.GetPeerStates()
ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
var p2p, relayed float64
for _, p := range peers {
if p.ConnStatus != peer.StatusConnected {
continue
}
if p.Relayed {
relayed++
continue
}
p2p++
if latency := p.Latency.Seconds(); latency > 0 {
ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
}
}
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
}
func boolToFloat(b bool) float64 {
if b {
return 1
}
return 0
}
// warnIfNotLoopback logs a warning when the listen address cannot be
// confirmed to be local-only, since the endpoint exposes peer and
// connectivity details without authentication.
func warnIfNotLoopback(addr string) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return
}
if host == "localhost" {
return
}
if ip, err := netip.ParseAddr(host); err == nil && ip.Unmap().IsLoopback() {
return
}
log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
}

View File

@@ -1,97 +0,0 @@
package localmetrics
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/peer"
)
type stubStatus struct {
peers []peer.State
management peer.ManagementState
signal peer.SignalState
}
func (s *stubStatus) GetPeerStates() []peer.State { return s.peers }
func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal }
func testStatus() *stubStatus {
return &stubStatus{
management: peer.ManagementState{Connected: true},
signal: peer.SignalState{Connected: true},
peers: []peer.State{
{FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
{FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
{FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
{FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
},
}
}
func TestCollector(t *testing.T) {
c := newCollector(testStatus())
expected := `
# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
# TYPE netbird_management_connected gauge
netbird_management_connected 1
# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
# TYPE netbird_peer_latency_seconds gauge
netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
# HELP netbird_peers Number of peers known to this client.
# TYPE netbird_peers gauge
netbird_peers 4
# HELP netbird_peers_connected Number of connected peers by connection type.
# TYPE netbird_peers_connected gauge
netbird_peers_connected{connection_type="p2p"} 2
netbird_peers_connected{connection_type="relay"} 1
# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
# TYPE netbird_signal_connected gauge
netbird_signal_connected 1
`
require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
}
func TestServe(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "must find a free port")
addr := ln.Addr().String()
require.NoError(t, ln.Close())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
m := NewManager(ctx, testStatus(), nil)
m.Reconcile(true, addr)
var body string
require.Eventually(t, func() bool {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
if err != nil {
return false
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
body = string(data)
return true
}, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
assert.Contains(t, body, "netbird_peers 4")
assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
}

View File

@@ -45,13 +45,30 @@ func (m *influxDBMetrics) RecordConnectionStages(
isReconnection bool,
timestamps ConnectionStageTimestamps,
) {
signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
}
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
}
if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
}
attemptType := "initial"
if isReconnection {
attemptType = "reconnection"
}
connTypeStr := connectionType.String()
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
agentInfo.DeploymentType.String(),
connTypeStr,
attemptType(isReconnection),
attemptType,
agentInfo.Version,
agentInfo.OS,
agentInfo.Arch,
@@ -77,7 +94,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
m.trimLocked()
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
}
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {

View File

@@ -89,21 +89,6 @@ type ConnectionStageTimestamps struct {
WgHandshakeSuccess time.Time
}
// Durations returns the stage durations in seconds. A duration is zero when
// either of its timestamps is missing.
func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
}
if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
}
if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
}
return signalingToConnection, connectionToWgHandshake, total
}
// String returns a human-readable representation of the connection stage timestamps
func (c ConnectionStageTimestamps) String() string {
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
@@ -294,11 +279,3 @@ func (c *ClientMetrics) stopPushLocked() {
c.wg.Wait()
c.push.Store(nil)
}
// attemptType returns the metric label for an initial vs reconnection attempt.
func attemptType(isReconnection bool) string {
if isReconnection {
return "reconnection"
}
return "initial"
}

View File

@@ -2,24 +2,10 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
// NewClientMetrics creates a new ClientMetrics instance
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
return &ClientMetrics{
impl: newPrometheusMetrics(newInfluxDBMetrics()),
impl: newInfluxDBMetrics(),
agentInfo: agentInfo,
}
}
// PrometheusGatherer returns the registry with the mirrored Prometheus
// metrics, or nil when unavailable.
func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
if c == nil {
return nil
}
if pm, ok := c.impl.(*prometheusMetrics); ok {
return pm.Gatherer()
}
return nil
}

View File

@@ -1,119 +0,0 @@
//go:build !js
package metrics
import (
"context"
"io"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// prometheusMetrics mirrors recorded client metrics into a Prometheus
// registry for the local /metrics endpoint, then delegates to the wrapped
// implementation. Export and Reset pass through untouched: Prometheus
// metrics are cumulative and pull-based.
type prometheusMetrics struct {
next metricsImplementation
registry *prometheus.Registry
connectionStages *prometheus.HistogramVec
syncDuration prometheus.Histogram
syncPhaseDuration *prometheus.HistogramVec
loginDuration *prometheus.HistogramVec
}
func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
m := &prometheusMetrics{
next: next,
registry: prometheus.NewRegistry(),
connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_peer_connection_stage_duration_seconds",
Help: "Duration of peer connection establishment stages.",
Buckets: connectionBuckets,
}, []string{"stage", "connection_type", "attempt_type"}),
syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "netbird_sync_duration_seconds",
Help: "Duration of management sync message processing.",
Buckets: prometheus.DefBuckets,
}),
syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_sync_phase_duration_seconds",
Help: "Duration of individual sync processing phases.",
Buckets: prometheus.DefBuckets,
}, []string{"phase"}),
loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_login_duration_seconds",
Help: "Duration of logins to the management service.",
Buckets: prometheus.DefBuckets,
}, []string{"success"}),
}
m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
return m
}
// Gatherer returns the registry holding the mirrored metrics.
func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
return m.registry
}
// RecordConnectionStages implements metricsImplementation.
func (m *prometheusMetrics) RecordConnectionStages(
ctx context.Context,
agentInfo AgentInfo,
connectionPairID string,
connectionType ConnectionType,
isReconnection bool,
timestamps ConnectionStageTimestamps,
) {
attempt := attemptType(isReconnection)
connType := connectionType.String()
signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
if signalingToConnection > 0 {
m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
}
if connectionToWgHandshake > 0 {
m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
}
if total > 0 {
m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
}
m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
}
// RecordSyncDuration implements metricsImplementation.
func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
m.syncDuration.Observe(duration.Seconds())
m.next.RecordSyncDuration(ctx, agentInfo, duration)
}
// RecordSyncPhase implements metricsImplementation.
func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
}
// RecordLoginDuration implements metricsImplementation.
func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
}
// Export implements metricsImplementation by delegating to the wrapped
// implementation; Prometheus metrics are pulled via the registry instead.
func (m *prometheusMetrics) Export(w io.Writer) error {
return m.next.Export(w)
}
// Reset implements metricsImplementation by delegating to the wrapped
// implementation; Prometheus metrics must not be cleared on push.
func (m *prometheusMetrics) Reset() {
m.next.Reset()
}

View File

@@ -65,6 +65,20 @@ type WgConfig struct {
PreSharedKey *wgtypes.Key
}
// RosenpassKeyResolver lets the handshaker fill the fingerprint/cache fields of an
// offer/answer without depending on the Rosenpass manager directly. Implemented by
// rosenpass.Manager and wired in by the engine.
type RosenpassKeyResolver interface {
// LocalPubKeyHash is the SHA256 of our own Rosenpass public key.
LocalPubKeyHash() []byte
// RemotePubKeyAck is the SHA256 of the remote peer's cached key (nil if we do
// not hold it), sent back as an acknowledgement.
RemotePubKeyAck(remoteWgKey string) []byte
// RemoteHasLocalKey reports whether the peer already holds our key, so the full
// key may be omitted.
RemoteHasLocalKey(remoteWgKey string) bool
}
type RosenpassConfig struct {
// RosenpassPubKey is this peer's Rosenpass public key
PubKey []byte
@@ -72,6 +86,10 @@ type RosenpassConfig struct {
Addr string
PermissiveMode bool
// KeyResolver drives fingerprint-based key caching over signalling. Nil when
// Rosenpass is disabled, which makes the handshaker always send the full key.
KeyResolver RosenpassKeyResolver
}
// ConnConfig is a peer Connection configuration

View File

@@ -33,8 +33,13 @@ type OfferAnswer struct {
// Version of NetBird Agent
Version string
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
// This value is the local Rosenpass server public key when sending the message
// This value is the local Rosenpass server public key when sending the message.
// May be empty on send when the remote peer has acknowledged already holding it (see RosenpassPubKeyAck).
RosenpassPubKey []byte
// RosenpassPubKeyHash is the SHA256 of the sender's own RosenpassPubKey. Always set when Rosenpass is enabled.
RosenpassPubKeyHash []byte
// RosenpassPubKeyAck is the SHA256 of the remote peer's key the sender holds cached; empty means "send it in full".
RosenpassPubKeyAck []byte
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
@@ -209,11 +214,11 @@ func (h *Handshaker) sendAnswer() error {
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
answer := OfferAnswer{
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassPubKey: h.config.RosenpassConfig.PubKey,
RosenpassAddr: h.config.RosenpassConfig.Addr,
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassAddr: h.config.RosenpassConfig.Addr,
}
h.setRosenpassPubKey(&answer)
if h.ice != nil && h.RemoteICESupported() {
uFrag, pwd := h.ice.GetLocalUserCredentials()
@@ -230,6 +235,30 @@ func (h *Handshaker) buildOfferAnswer() OfferAnswer {
return answer
}
// setRosenpassPubKey fills the Rosenpass key fields of an outgoing offer/answer.
// With a resolver wired it advertises our key hash and the ack for the remote key
// we hold, and includes the full public key only when the peer has not yet
// acknowledged holding it. Without a resolver (Rosenpass disabled, or an older
// code path) it always sends the full key, preserving the previous behaviour.
func (h *Handshaker) setRosenpassPubKey(answer *OfferAnswer) {
localKey := h.config.RosenpassConfig.PubKey
if len(localKey) == 0 {
return
}
resolver := h.config.RosenpassConfig.KeyResolver
if resolver == nil {
answer.RosenpassPubKey = localKey
return
}
answer.RosenpassPubKeyHash = resolver.LocalPubKeyHash()
answer.RosenpassPubKeyAck = resolver.RemotePubKeyAck(h.config.Key)
if !resolver.RemoteHasLocalKey(h.config.Key) {
answer.RosenpassPubKey = localKey
}
}
func (h *Handshaker) updateRemoteICEState(offer *OfferAnswer) {
hasICE := offer.hasICECredentials()
prev := h.remoteICESupported.Swap(hasICE)

View File

@@ -0,0 +1,65 @@
package peer
import (
"testing"
"github.com/stretchr/testify/require"
)
type fakeRPResolver struct {
localHash []byte
ack []byte
hasLocal bool
}
func (f fakeRPResolver) LocalPubKeyHash() []byte { return f.localHash }
func (f fakeRPResolver) RemotePubKeyAck(string) []byte { return f.ack }
func (f fakeRPResolver) RemoteHasLocalKey(remote string) bool { return f.hasLocal }
func TestSetRosenpassPubKey_NoResolverAlwaysSendsFullKey(t *testing.T) {
localKey := []byte{1, 2, 3}
h := &Handshaker{config: ConnConfig{RosenpassConfig: RosenpassConfig{PubKey: localKey}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Equal(t, localKey, a.RosenpassPubKey)
require.Nil(t, a.RosenpassPubKeyHash)
require.Nil(t, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_ResolverIncludesFullKeyUntilAcked(t *testing.T) {
localKey := []byte{1, 2, 3}
res := fakeRPResolver{localHash: []byte{9}, ack: []byte{8}, hasLocal: false}
h := &Handshaker{config: ConnConfig{Key: "peerA", RosenpassConfig: RosenpassConfig{PubKey: localKey, KeyResolver: res}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Equal(t, localKey, a.RosenpassPubKey, "full key must be sent until the peer acks it")
require.Equal(t, []byte{9}, a.RosenpassPubKeyHash)
require.Equal(t, []byte{8}, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_ResolverOmitsFullKeyOnceAcked(t *testing.T) {
localKey := []byte{1, 2, 3}
res := fakeRPResolver{localHash: []byte{9}, ack: []byte{8}, hasLocal: true}
h := &Handshaker{config: ConnConfig{Key: "peerA", RosenpassConfig: RosenpassConfig{PubKey: localKey, KeyResolver: res}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Nil(t, a.RosenpassPubKey, "full key must be omitted once the peer holds it")
require.Equal(t, []byte{9}, a.RosenpassPubKeyHash)
require.Equal(t, []byte{8}, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_DisabledSetsNothing(t *testing.T) {
h := &Handshaker{config: ConnConfig{RosenpassConfig: RosenpassConfig{}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Nil(t, a.RosenpassPubKey)
require.Nil(t, a.RosenpassPubKeyHash)
}

View File

@@ -61,11 +61,13 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
UFrag: offerAnswer.IceCredentials.UFrag,
Pwd: offerAnswer.IceCredentials.Pwd,
},
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassAddr: offerAnswer.RosenpassAddr,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassPubKeyHash: offerAnswer.RosenpassPubKeyHash,
RosenpassPubKeyAck: offerAnswer.RosenpassPubKeyAck,
RosenpassAddr: offerAnswer.RosenpassAddr,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,
})
if err != nil {
return err

View File

@@ -1172,18 +1172,6 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
return maps.Clone(d.resolvedDomainsStates)
}
// GetPeerStates returns a snapshot of all known peer states.
func (d *Status) GetPeerStates() []State {
d.mux.RLock()
defer d.mux.RUnlock()
states := make([]State, 0, len(d.peers))
for _, state := range d.peers {
states = append(states, state)
}
return states
}
// GetFullStatus gets full status
func (d *Status) GetFullStatus() FullStatus {
fullStatus := FullStatus{

View File

@@ -102,9 +102,6 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
LocalMetricsEnabled *bool
LocalMetricsAddress *string
}
// Config Configuration type
@@ -145,11 +142,6 @@ type Config struct {
DNSLabels domain.List
// LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
LocalMetricsEnabled bool
// LocalMetricsAddress is the listen address of the local /metrics endpoint.
LocalMetricsAddress string
// SSHKey is a private SSH key in a PEM format
SSHKey string
@@ -394,18 +386,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
config.LocalMetricsEnabled = *input.LocalMetricsEnabled
updated = true
}
if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
config.LocalMetricsAddress = *input.LocalMetricsAddress
updated = true
}
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor
@@ -730,12 +710,6 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
config.LocalMetricsAddress = v
logApplied(mdm.KeyLocalMetricsAddress, v)
}
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the

View File

@@ -130,32 +130,6 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
}
func TestApply_MDMLocalMetrics(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: tmp,
LocalMetricsEnabled: boolPtr(false),
})
require.NoError(t, err)
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress))
}
func TestApply_MDMLazyConnection(t *testing.T) {
cases := []struct {
name string

View File

@@ -0,0 +1,62 @@
package rosenpass
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
)
func newCacheTestManager(spk []byte) *Manager {
return &Manager{
spk: spk,
remotePubKeys: make(map[string][]byte),
remoteHasLocalKey: make(map[string]bool),
}
}
func TestResolveRemotePubKey(t *testing.T) {
m := newCacheTestManager([]byte{0x01, 0x02})
full := bytes.Repeat([]byte{0xAB}, 64)
// a received full key is cached and returned
require.Equal(t, full, m.ResolveRemotePubKey("peerA", full, nil))
// a later hash-only message resolves from the cache
require.Equal(t, full, m.ResolveRemotePubKey("peerA", nil, rawRosenpassKeyHash(full)))
// hash mismatch is a cache miss
require.Nil(t, m.ResolveRemotePubKey("peerA", nil, bytes.Repeat([]byte{0x01}, 32)))
// no key and no hash (remote without Rosenpass) resolves to nil
require.Nil(t, m.ResolveRemotePubKey("peerB", nil, nil))
}
func TestRemotePubKeyAck(t *testing.T) {
m := newCacheTestManager([]byte{0x01})
// unknown peer -> no ack (signals "send me the full key")
require.Nil(t, m.RemotePubKeyAck("peerA"))
full := bytes.Repeat([]byte{0x09}, 48)
m.ResolveRemotePubKey("peerA", full, nil)
require.Equal(t, rawRosenpassKeyHash(full), m.RemotePubKeyAck("peerA"))
}
func TestSetRemoteAckAndRemoteHasLocalKey(t *testing.T) {
m := newCacheTestManager(bytes.Repeat([]byte{0x07}, 100))
require.False(t, m.RemoteHasLocalKey("peerA"))
// an ack matching our own key hash marks the peer as holding our key
m.SetRemoteAck("peerA", m.LocalPubKeyHash())
require.True(t, m.RemoteHasLocalKey("peerA"))
// empty ack clears it
m.SetRemoteAck("peerA", nil)
require.False(t, m.RemoteHasLocalKey("peerA"))
// a non-matching ack does not count
m.SetRemoteAck("peerA", bytes.Repeat([]byte{0x01}, 32))
require.False(t, m.RemoteHasLocalKey("peerA"))
}

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -28,6 +29,11 @@ func hashRosenpassKey(key []byte) string {
return hex.EncodeToString(hasher.Sum(nil))
}
func rawRosenpassKeyHash(key []byte) []byte {
sum := sha256.Sum256(key)
return sum[:]
}
// rpServer is the subset of rp.Server used by Manager. Defined as an interface
// so tests can substitute a mock without spinning up a real UDP server.
type rpServer interface {
@@ -50,12 +56,29 @@ type Manager struct {
lock sync.Mutex
port int
wgIface PresharedKeySetter
// remotePubKeys caches remote peers' full Rosenpass public keys keyed by their
// WireGuard public key, so a peer that already sent us its (large) key over
// signalling need only send its hash on subsequent offers/answers. RAM only —
// never persisted (1000 peers x ~512KB would be ~512MB on disk).
remotePubKeys map[string][]byte
// remoteHasLocalKey tracks, per remote WireGuard key, whether that peer has
// acknowledged holding our current Rosenpass public key, letting us omit it.
remoteHasLocalKey map[string]bool
}
// NewManager creates a new Rosenpass manager. localWgKey is the local
// WireGuard public key, used to derive the per-peer rendezvous key.
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
public, secret, err := rp.GenerateKeyPair()
// WireGuard public key, used to derive the per-peer rendezvous key. When stateDir
// is non-empty the static keypair is persisted under it and reused across
// restarts, keeping the public key (and the fingerprint peers cache) stable;
// an empty stateDir keeps the previous behaviour of an ephemeral per-run keypair.
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key, stateDir string) (*Manager, error) {
var keyPath string
if stateDir != "" {
keyPath = filepath.Join(stateDir, keypairFileName)
}
public, secret, err := loadOrGenerateKeypair(keyPath)
if err != nil {
return nil, err
}
@@ -76,8 +99,10 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtype
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
// replace it with a fresh handler on each Run() to clear stale peer
// state from previous engine sessions.
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
remotePubKeys: make(map[string][]byte),
remoteHasLocalKey: make(map[string]bool),
}, nil
}
@@ -90,6 +115,68 @@ func (m *Manager) GetAddress() *net.UDPAddr {
return &net.UDPAddr{Port: m.port}
}
// LocalPubKeyHash returns the raw SHA256 of the local Rosenpass public key. It is
// advertised on every offer/answer so the remote peer can tell (via its cache)
// whether it already holds our full key.
func (m *Manager) LocalPubKeyHash() []byte {
return rawRosenpassKeyHash(m.spk)
}
// RemotePubKeyAck returns the SHA256 of the remote peer's cached public key, used
// as the acknowledgement we send back. Nil means we do not hold the peer's key,
// which signals the peer to include its full key next time.
func (m *Manager) RemotePubKeyAck(remoteWgKey string) []byte {
m.lock.Lock()
defer m.lock.Unlock()
key, ok := m.remotePubKeys[remoteWgKey]
if !ok {
return nil
}
return rawRosenpassKeyHash(key)
}
// RemoteHasLocalKey reports whether the remote peer acknowledged holding our
// current public key, so we may omit the full key from the next offer/answer.
func (m *Manager) RemoteHasLocalKey(remoteWgKey string) bool {
m.lock.Lock()
defer m.lock.Unlock()
return m.remoteHasLocalKey[remoteWgKey]
}
// ResolveRemotePubKey reconciles the Rosenpass key material from a received
// offer/answer: it caches a received full key, or — when only a hash was sent —
// returns the cached key matching that hash. It returns nil when the remote peer
// does not use Rosenpass (no key, no hash) or on a cache miss (hash sent but not
// held); a miss self-heals because our resulting empty ack makes the peer resend
// its full key.
func (m *Manager) ResolveRemotePubKey(remoteWgKey string, full, hash []byte) []byte {
m.lock.Lock()
defer m.lock.Unlock()
if len(full) > 0 {
m.remotePubKeys[remoteWgKey] = full
return full
}
if len(hash) == 0 {
return nil
}
if cached, ok := m.remotePubKeys[remoteWgKey]; ok && bytes.Equal(rawRosenpassKeyHash(cached), hash) {
return cached
}
return nil
}
// SetRemoteAck records whether the remote peer's acknowledgement matches our
// current public key hash, i.e. whether it already holds our key.
func (m *Manager) SetRemoteAck(remoteWgKey string, ack []byte) {
m.lock.Lock()
defer m.lock.Unlock()
m.remoteHasLocalKey[remoteWgKey] = len(ack) > 0 && bytes.Equal(ack, rawRosenpassKeyHash(m.spk))
}
// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
// Defense in depth against issue #4341 (Android crash): if Run() has not

View File

@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
// issue #4341 cannot occur in the window between NewManager and Run().
func TestNewManager_PreInitializesHandler(t *testing.T) {
psk := wgtypes.Key{}
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}, "")
require.NoError(t, err)
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
}

View File

@@ -0,0 +1,92 @@
package rosenpass
import (
"context"
"fmt"
"os"
rp "cunicu.li/go-rosenpass"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/util"
)
const (
// keypairFileName is the file, relative to the state directory, that holds
// the persisted local Rosenpass static keypair.
keypairFileName = "rosenpass_key.json"
// rpStaticPublicKeySize is the byte length of a Rosenpass (Classic McEliece)
// static public key as produced by the pinned go-rosenpass version. Used as a
// version-compatibility guard: a persisted key of any other size is treated as
// stale and regenerated instead of being fed to go-rosenpass (which would fail).
rpStaticPublicKeySize = 524160
// keypairFormatVersion is bumped whenever the on-disk representation changes so
// old files are discarded and regenerated rather than misparsed.
keypairFormatVersion = 1
)
// persistedKeypair is the on-disk representation of the local Rosenpass static
// keypair. Keys are stored raw (base64 via JSON) with the same restricted 0600
// permission as the WireGuard private key and other client secrets.
type persistedKeypair struct {
Version int `json:"version"`
PublicKey []byte `json:"public_key"`
SecretKey []byte `json:"secret_key"`
}
// loadOrGenerateKeypair returns a Rosenpass static keypair. When keyPath is set
// and holds a valid persisted keypair it is reused, so the local public key —
// and therefore the fingerprint advertised to remote peers over signalling —
// stays stable across restarts. Otherwise a fresh keypair is generated and, when
// keyPath is set, persisted for subsequent runs. A missing or corrupt file is not
// fatal: it degrades to generating an ephemeral keypair, matching the pre-persistence
// behaviour.
func loadOrGenerateKeypair(keyPath string) (public []byte, secret []byte, err error) {
if keyPath != "" {
public, secret, err = loadKeypair(keyPath)
switch {
case err == nil:
return public, secret, nil
case os.IsNotExist(err):
// first run for this state dir; fall through to generate
default:
log.Warnf("failed to load persisted rosenpass keypair, generating a new one: %v", err)
}
}
pub, sec, err := rp.GenerateKeyPair()
if err != nil {
return nil, nil, fmt.Errorf("generate rosenpass key pair: %w", err)
}
if keyPath != "" {
if err := saveKeypair(keyPath, pub, sec); err != nil {
log.Warnf("failed to persist rosenpass keypair, key will be regenerated on next restart: %v", err)
}
}
return pub, sec, nil
}
func loadKeypair(keyPath string) ([]byte, []byte, error) {
var kp persistedKeypair
if _, err := util.ReadJson(keyPath, &kp); err != nil {
return nil, nil, err
}
if kp.Version != keypairFormatVersion || len(kp.PublicKey) != rpStaticPublicKeySize || len(kp.SecretKey) == 0 {
return nil, nil, fmt.Errorf("persisted rosenpass keypair is incompatible (version %d, public %d bytes, secret %d bytes)", kp.Version, len(kp.PublicKey), len(kp.SecretKey))
}
return kp.PublicKey, kp.SecretKey, nil
}
func saveKeypair(keyPath string, public, secret []byte) error {
return util.WriteJsonWithRestrictedPermission(context.Background(), keyPath, persistedKeypair{
Version: keypairFormatVersion,
PublicKey: public,
SecretKey: secret,
})
}

View File

@@ -0,0 +1,66 @@
package rosenpass
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestLoadOrGenerateKeypair_EphemeralWhenNoPath(t *testing.T) {
pub, sec, err := loadOrGenerateKeypair("")
require.NoError(t, err)
require.Len(t, pub, rpStaticPublicKeySize)
require.NotEmpty(t, sec)
}
func TestLoadOrGenerateKeypair_PersistsAndReloads(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
pub1, sec1, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
info, err := os.Stat(keyPath)
require.NoError(t, err, "keypair file must be written")
require.Equal(t, os.FileMode(0600), info.Mode().Perm(), "keypair file must be 0600")
pub2, sec2, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.True(t, bytes.Equal(pub1, pub2), "public key must be stable across reloads")
require.True(t, bytes.Equal(sec1, sec2), "secret key must be stable across reloads")
}
func TestLoadOrGenerateKeypair_RegeneratesOnCorruptFile(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
require.NoError(t, os.WriteFile(keyPath, []byte("not json"), 0600))
pub, sec, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.Len(t, pub, rpStaticPublicKeySize)
require.NotEmpty(t, sec)
// the corrupt file must have been overwritten with a valid, reloadable keypair
pub2, _, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.True(t, bytes.Equal(pub, pub2))
}
func TestLoadOrGenerateKeypair_RegeneratesOnVersionMismatch(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
pub1, _, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
// rewrite with a bumped/unknown format version -> must be discarded
bs, err := json.Marshal(persistedKeypair{Version: keypairFormatVersion + 1, PublicKey: pub1, SecretKey: []byte{0x01}})
require.NoError(t, err)
require.NoError(t, os.WriteFile(keyPath, bs, 0600))
pub2, sec2, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.Len(t, pub2, rpStaticPublicKeySize)
require.NotEmpty(t, sec2)
}

View File

@@ -47,8 +47,6 @@ const (
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
KeyEnableLocalMetrics = "enableLocalMetrics"
KeyLocalMetricsAddress = "localMetricsAddress"
// Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator

View File

@@ -343,8 +343,6 @@ type LoginRequest struct {
DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -660,20 +658,6 @@ func (x *LoginRequest) GetDisableIpv6() bool {
return false
}
func (x *LoginRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *LoginRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"`
@@ -4226,8 +4210,6 @@ type SetConfigRequest struct {
DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4507,20 +4489,6 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
return false
}
func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *SetConfigRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
type SetConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -7019,7 +6987,7 @@ var File_daemon_proto protoreflect.FileDescriptor
const file_daemon_proto_rawDesc = "" +
"\n" +
"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
"\fEmptyRequest\"\x92\x14\n" +
"\fEmptyRequest\"\xef\x12\n" +
"\fLoginRequest\x12\x1a\n" +
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
@@ -7064,9 +7032,7 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
"\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -7094,9 +7060,7 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_address\"\xb5\x01\n" +
"\r_disable_ipv6\"\xb5\x01\n" +
"\rLoginResponse\x12$\n" +
"\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" +
"\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" +
@@ -7389,7 +7353,7 @@ const file_daemon_proto_rawDesc = "" +
"\f_profileNameB\v\n" +
"\t_username\"'\n" +
"\x15SwitchProfileResponse\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -7429,9 +7393,7 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
"\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -7456,9 +7418,7 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_address\"\x13\n" +
"\r_disable_ipv6\"\x13\n" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +

View File

@@ -242,9 +242,6 @@ message LoginRequest {
optional bool disableSSHAuth = 38;
optional int32 sshJWTCacheTTL = 39;
optional bool disable_ipv6 = 40;
optional bool enable_local_metrics = 41;
optional string local_metrics_address = 42;
}
message LoginResponse {
@@ -760,9 +757,6 @@ message SetConfigRequest {
optional bool disableSSHAuth = 33;
optional int32 sshJWTCacheTTL = 34;
optional bool disable_ipv6 = 35;
optional bool enable_local_metrics = 36;
optional string local_metrics_address = 37;
}
message SetConfigResponse{}

View File

@@ -301,8 +301,6 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
})
}
@@ -348,9 +346,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.EnableSSHLocalPortForwarding != nil ||
msg.EnableSSHRemotePortForwarding != nil ||
msg.DisableSSHAuth != nil ||
msg.SshJWTCacheTTL != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
msg.SshJWTCacheTTL != nil
}
// loginRequestHasConfigOverrides reports whether the LoginRequest
@@ -385,9 +381,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
msg.BlockInbound != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
msg.BlockInbound != nil
}
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
@@ -428,8 +422,6 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
})
}

View File

@@ -23,9 +23,6 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/prometheus/client_golang/prometheus"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -102,7 +99,6 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
localMetrics *localmetrics.Manager
probeThrottle *probeThrottle
persistSyncResponse bool
@@ -159,28 +155,9 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
s.sleepHandler = sleephandler.New(agent)
s.startSleepDetector()
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
return s
}
// clientMetricsGatherer returns the Prometheus gatherer of the running
// engine's client metrics, or nil when no engine is running.
func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
s.mutex.Lock()
connectClient := s.connectClient
s.mutex.Unlock()
if connectClient == nil {
return nil
}
engine := connectClient.Engine()
if engine == nil {
return nil
}
return engine.GetClientMetrics().PrometheusGatherer()
}
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -261,7 +238,6 @@ func (s *Server) Start() error {
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if s.sessionWatcher == nil {
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
@@ -440,18 +416,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
updatedConf, err := profilemanager.UpdateConfig(config)
if err != nil {
if _, err := profilemanager.UpdateConfig(config); err != nil {
log.Errorf("failed to update profile config: %v", err)
return nil, fmt.Errorf("failed to update profile config: %w", err)
}
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
}
}
return &proto.SetConfigResponse{}, nil
}
@@ -521,8 +490,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
config.RosenpassEnabled = msg.RosenpassEnabled
config.RosenpassPermissive = msg.RosenpassPermissive
config.LocalMetricsEnabled = msg.EnableLocalMetrics
config.LocalMetricsAddress = msg.LocalMetricsAddress
config.DisableAutoConnect = msg.DisableAutoConnect
config.ServerSSHAllowed = msg.ServerSSHAllowed
config.NetworkMonitor = msg.NetworkMonitor
@@ -978,7 +945,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
s.clientRunning = true
s.clientRunningChan = make(chan struct{})

View File

@@ -132,30 +132,6 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
}, v.GetFields())
}
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
enabled := false
addr := "0.0.0.0:9999"
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
EnableLocalMetrics: &enabled,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{
mdm.KeyEnableLocalMetrics,
mdm.KeyLocalMetricsAddress,
}, v.GetFields())
}
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled).

View File

@@ -73,8 +73,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
enableLocalMetrics := true
localMetricsAddress := "127.0.0.1:9292"
req := &proto.SetConfigRequest{
ProfileName: profName,
@@ -106,8 +104,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
EnableLocalMetrics: &enableLocalMetrics,
LocalMetricsAddress: &localMetricsAddress,
}
_, err = s.SetConfig(ctx, req)
@@ -154,8 +150,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, uint16(mtu), cfg.MTU)
require.NotNil(t, cfg.SSHJWTCacheTTL)
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
verifyAllFieldsCovered(t, req)
}
@@ -208,8 +202,6 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
"EnableLocalMetrics": true,
"LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -269,8 +261,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
"disable-ssh-auth": "DisableSSHAuth",
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
"enable-local-metrics": "EnableLocalMetrics",
"local-metrics-address": "LocalMetricsAddress",
}
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).

3
go.mod
View File

@@ -98,7 +98,6 @@ require (
github.com/pires/go-proxyproto v0.11.0
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/quic-go/quic-go v0.55.0
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
@@ -250,7 +249,6 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koron/go-ssdp v0.0.4 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
@@ -291,6 +289,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect

View File

@@ -51,10 +51,17 @@ type CredentialPayload struct {
WgListenPort int
Credential *Credential
RosenpassPubKey []byte
RosenpassAddr string
RelaySrvAddress string
RelaySrvIP netip.Addr
SessionID []byte
// RosenpassPubKeyHash is the SHA256 of the sender's own RosenpassPubKey (empty
// when Rosenpass is disabled). RosenpassPubKey may be omitted when the peer has
// already acknowledged this hash. See RosenpassConfig in the proto.
RosenpassPubKeyHash []byte
// RosenpassPubKeyAck is the SHA256 of the remote peer's key the sender holds
// cached; empty means "send me the full key".
RosenpassPubKeyAck []byte
RosenpassAddr string
RelaySrvAddress string
RelaySrvIP netip.Addr
SessionID []byte
}
// UnMarshalCredential parses the credentials from the message and returns a Credential instance
@@ -78,8 +85,10 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload)
WgListenPort: uint32(p.WgListenPort),
NetBirdVersion: version.NetbirdVersion(),
RosenpassConfig: &proto.RosenpassConfig{
RosenpassPubKey: p.RosenpassPubKey,
RosenpassServerAddr: p.RosenpassAddr,
RosenpassPubKey: p.RosenpassPubKey,
RosenpassServerAddr: p.RosenpassAddr,
RosenpassPubKeyHash: p.RosenpassPubKeyHash,
AcknowledgedRosenpassPubKeyHash: p.RosenpassPubKeyAck,
},
SessionId: p.SessionID,
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.26.0
// protoc v3.21.12
// protoc v6.33.1
// source: signalexchange.proto
package proto
@@ -399,6 +399,17 @@ type RosenpassConfig struct {
RosenpassPubKey []byte `protobuf:"bytes,1,opt,name=rosenpassPubKey,proto3" json:"rosenpassPubKey,omitempty"`
// rosenpassServerAddr is an IP:port of the rosenpass service
RosenpassServerAddr string `protobuf:"bytes,2,opt,name=rosenpassServerAddr,proto3" json:"rosenpassServerAddr,omitempty"`
// rosenpassPubKeyHash is the SHA256 of the sender's own rosenpassPubKey. It is
// always set when Rosenpass is enabled and lets the receiver detect (via a
// per-peer cache) whether it already holds the sender's full public key,
// avoiding re-sending the large key on every offer/answer.
RosenpassPubKeyHash []byte `protobuf:"bytes,3,opt,name=rosenpassPubKeyHash,proto3" json:"rosenpassPubKeyHash,omitempty"`
// acknowledgedRosenpassPubKeyHash is the SHA256 of the remote peer's rosenpassPubKey
// that the sender currently holds cached. When it matches the receiver's own key hash
// the receiver may omit its full rosenpassPubKey from the message. Empty means the
// sender does not have the remote key and needs it sent in full. Absent from peers
// that predate this field, which keeps them receiving the full key as before.
AcknowledgedRosenpassPubKeyHash []byte `protobuf:"bytes,4,opt,name=acknowledgedRosenpassPubKeyHash,proto3" json:"acknowledgedRosenpassPubKeyHash,omitempty"`
}
func (x *RosenpassConfig) Reset() {
@@ -447,6 +458,20 @@ func (x *RosenpassConfig) GetRosenpassServerAddr() string {
return ""
}
func (x *RosenpassConfig) GetRosenpassPubKeyHash() []byte {
if x != nil {
return x.RosenpassPubKeyHash
}
return nil
}
func (x *RosenpassConfig) GetAcknowledgedRosenpassPubKeyHash() []byte {
if x != nil {
return x.AcknowledgedRosenpassPubKeyHash
}
return nil
}
var File_signalexchange_proto protoreflect.FileDescriptor
var file_signalexchange_proto_rawDesc = []byte{
@@ -506,27 +531,35 @@ var file_signalexchange_proto_rawDesc = []byte{
0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0xe9, 0x01, 0x0a, 0x0f, 0x52,
0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28,
0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65,
0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f,
0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x48, 0x61, 0x73,
0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x12, 0x48, 0x0a, 0x1f,
0x61, 0x63, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x64, 0x52, 0x6f, 0x73, 0x65,
0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x18,
0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x1f, 0x61, 0x63, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64,
0x67, 0x65, 0x64, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b,
0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61,
0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e,
0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68,
0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65,
0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61,
0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67,
0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72,
0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01,
0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@@ -86,4 +86,15 @@ message RosenpassConfig {
bytes rosenpassPubKey = 1;
// rosenpassServerAddr is an IP:port of the rosenpass service
string rosenpassServerAddr = 2;
// rosenpassPubKeyHash is the SHA256 of the sender's own rosenpassPubKey. It is
// always set when Rosenpass is enabled and lets the receiver detect (via a
// per-peer cache) whether it already holds the sender's full public key,
// avoiding re-sending the large key on every offer/answer.
bytes rosenpassPubKeyHash = 3;
// acknowledgedRosenpassPubKeyHash is the SHA256 of the remote peer's rosenpassPubKey
// that the sender currently holds cached. When it matches the receiver's own key hash
// the receiver may omit its full rosenpassPubKey from the message. Empty means the
// sender does not have the remote key and needs it sent in full. Absent from peers
// that predate this field, which keeps them receiving the full key as before.
bytes acknowledgedRosenpassPubKeyHash = 4;
}