mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 03:39:07 +02:00
The debug-bundle paths that upload without a human picking a destination compiled the vendor endpoint in: the mobile clients and the desktop UI hold `https://upload.debug.netbird.io/upload-url` as a constant, the CLI defaults its flag to it, and the remote job falls back to it when nothing else is set. A self-hosted deployment therefore shipped peer logs, routes, DNS and firewall state to NetBird-run infrastructure without its operator ever configuring that, and had no way to point those paths anywhere else. #7147 and #7153 gave the remote job a per-job URL and an MDM override, but neither reaches the mobile, UI or CLI paths, and both fail open when unset. Publish the destination from the management server instead, on the channel that already carries stun/turn/signal/relay/flow/metrics: - `NetbirdConfig.debug.upload_url`, sourced from the new account setting `debug_bundle_upload_url` (REST + dashboard) and falling back to the new `DebugUpload.URL` in the management server config, which a self-hosted install can set once so a fresh account is not left on the vendor default. Both are validated as https-with-host where they are written; a change fans out to connected peers rather than waiting for the next login. - One resolver on the client, `debug.ResolveUploadURL`, used by every path: MDM override > explicitly named URL > destination published by management > the NetBird service, but only for a peer enrolled with NetBird's cloud. Anything else fails closed with ErrNoUploadDestination and the bundle stays local, which is the behaviour change: a self-hosted deployment that names no upload service no longer uploads at all. - The engine keeps the published value (`Engine.DebugUploadURL`) so the bundle paths, which run off the engine loop, do not have to read it back out of the opt-in sync-response store. - The daemon request grows `upload`, so "upload to wherever this deployment says" is expressible; an empty `uploadURL` no longer has to mean "no upload". The privilege gate is unchanged and still applies only to a URL the local caller named — a destination published by management is the operator naming their own service. - The desktop UI stops carrying a vendor URL of its own and sends the intent. Reported privately as GHSA-hf99-43rj-h577.
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-version"
|
|
nbversion "github.com/netbirdio/netbird/version"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
integrationsConfig "github.com/netbirdio/management-integrations/integrations/config"
|
|
|
|
"github.com/netbirdio/netbird/client/ssh/auth"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
|
|
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/shared/management/networkmap"
|
|
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
"github.com/netbirdio/netbird/shared/netiputil"
|
|
)
|
|
|
|
const (
|
|
// deprecatedRemotePeersVersion is the version of Netbird that introduced the NetworkMap.RemotePeers field, deprecated in favor of RemotePeers.
|
|
deprecatedRemotePeersVersion = "0.29.3"
|
|
)
|
|
|
|
// precomputedDeprecatedRemotePeersConstraint is the parsed ">= 0.29.3" constraint,
|
|
// built once at init since the bound is a compile-time constant.
|
|
var precomputedDeprecatedRemotePeersConstraint version.Constraints
|
|
|
|
func init() {
|
|
constraint, err := version.NewConstraint(">= " + deprecatedRemotePeersVersion)
|
|
if err != nil {
|
|
panic("parse deprecated remote peers version constraint: " + err.Error())
|
|
}
|
|
precomputedDeprecatedRemotePeersConstraint = constraint
|
|
}
|
|
|
|
// toNetbirdConfig converts the server configuration to the wire representation. It returns
|
|
// nil when no server config is set (the fan-out network-map path) because clients treat any
|
|
// non-nil config as authoritative: a config without a relay section is interpreted as relay
|
|
// disabled and wipes the clients' relay URLs.
|
|
func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
|
|
var stuns []*proto.HostConfig
|
|
for _, stun := range config.Stuns {
|
|
stuns = append(stuns, &proto.HostConfig{
|
|
Uri: stun.URI,
|
|
Protocol: ToResponseProto(stun.Proto),
|
|
})
|
|
}
|
|
|
|
var turns []*proto.ProtectedHostConfig
|
|
if config.TURNConfig != nil {
|
|
for _, turn := range config.TURNConfig.Turns {
|
|
var username string
|
|
var password string
|
|
if turnCredentials != nil {
|
|
username = turnCredentials.Payload
|
|
password = turnCredentials.Signature
|
|
} else {
|
|
username = turn.Username
|
|
password = turn.Password
|
|
}
|
|
turns = append(turns, &proto.ProtectedHostConfig{
|
|
HostConfig: &proto.HostConfig{
|
|
Uri: turn.URI,
|
|
Protocol: ToResponseProto(turn.Proto),
|
|
},
|
|
User: username,
|
|
Password: password,
|
|
})
|
|
}
|
|
}
|
|
|
|
var relayCfg *proto.RelayConfig
|
|
if config.Relay != nil && len(config.Relay.Addresses) > 0 {
|
|
relayCfg = &proto.RelayConfig{
|
|
Urls: config.Relay.Addresses,
|
|
}
|
|
|
|
if relayToken != nil {
|
|
relayCfg.TokenPayload = relayToken.Payload
|
|
relayCfg.TokenSignature = relayToken.Signature
|
|
}
|
|
}
|
|
|
|
var signalCfg *proto.HostConfig
|
|
if config.Signal != nil {
|
|
signalCfg = &proto.HostConfig{
|
|
Uri: config.Signal.URI,
|
|
Protocol: ToResponseProto(config.Signal.Proto),
|
|
}
|
|
}
|
|
|
|
nbConfig := &proto.NetbirdConfig{
|
|
Stuns: stuns,
|
|
Turns: turns,
|
|
Signal: signalCfg,
|
|
Relay: relayCfg,
|
|
}
|
|
|
|
if settings != nil {
|
|
nbConfig.Metrics = &proto.MetricsConfig{
|
|
Enabled: settings.MetricsPushEnabled,
|
|
}
|
|
}
|
|
|
|
// The account setting wins, the server config is the deployment-wide default
|
|
// a self-hosted install can set once so a fresh account is not left with the
|
|
// vendor fallback. Both are https-validated where they are written.
|
|
debugUploadURL := config.DebugUpload.URL
|
|
if settings != nil && settings.DebugBundleUploadURL != "" {
|
|
debugUploadURL = settings.DebugBundleUploadURL
|
|
}
|
|
if debugUploadURL != "" {
|
|
nbConfig.Debug = &proto.DebugConfig{UploadUrl: debugUploadURL}
|
|
}
|
|
|
|
return nbConfig
|
|
}
|
|
|
|
func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
|
|
netmask, _ := network.Net.Mask.Size()
|
|
fqdn := peer.FQDN(dnsName)
|
|
|
|
sshConfig := &proto.SSHConfig{
|
|
SshEnabled: peer.SSHEnabled || enableSSH,
|
|
}
|
|
|
|
if sshConfig.SshEnabled {
|
|
sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig)
|
|
}
|
|
|
|
peerConfig := &proto.PeerConfig{
|
|
Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
|
|
SshConfig: sshConfig,
|
|
Fqdn: fqdn,
|
|
RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
|
|
LazyConnectionEnabled: settings.LazyConnectionEnabled,
|
|
AutoUpdate: &proto.AutoUpdateSettings{
|
|
Version: settings.AutoUpdateVersion,
|
|
AlwaysUpdate: settings.AutoUpdateAlways,
|
|
},
|
|
}
|
|
|
|
if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil {
|
|
ones, _ := network.NetV6.Mask.Size()
|
|
v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones)
|
|
if b, err := netiputil.EncodePrefix(v6Prefix); err == nil {
|
|
peerConfig.AddressV6 = b
|
|
}
|
|
}
|
|
|
|
return peerConfig
|
|
}
|
|
|
|
func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*nmdata.PostureChecks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
|
|
// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
|
|
// whether the target peer supports IPv6. Routes and firewall rules are already
|
|
// filtered at the source (network map builder).
|
|
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
|
|
useSourcePrefixes := peer.SupportsSourcePrefixes()
|
|
localIsProxy := peer.ProxyMeta.Embedded
|
|
|
|
response := &proto.SyncResponse{
|
|
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution),
|
|
NetworkMap: &proto.NetworkMap{
|
|
Serial: networkMap.Network.CurrentSerial(),
|
|
Routes: networkmap.ToProtocolRoutes(networkMap.Routes),
|
|
DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort),
|
|
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution),
|
|
},
|
|
Checks: toProtocolChecks(ctx, checks),
|
|
}
|
|
|
|
nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings)
|
|
extendedConfig := integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings)
|
|
response.NetbirdConfig = extendedConfig
|
|
|
|
response.NetworkMap.PeerConfig = response.PeerConfig
|
|
|
|
remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers))
|
|
remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6, localIsProxy)
|
|
|
|
if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) {
|
|
response.RemotePeers = remotePeers
|
|
}
|
|
|
|
response.NetworkMap.RemotePeers = remotePeers
|
|
response.RemotePeersIsEmpty = len(remotePeers) == 0
|
|
response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty
|
|
|
|
response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6, localIsProxy)
|
|
|
|
firewallRules := networkmap.ToProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes)
|
|
response.NetworkMap.FirewallRules = firewallRules
|
|
response.NetworkMap.FirewallRulesIsEmpty = len(firewallRules) == 0
|
|
|
|
routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules)
|
|
response.NetworkMap.RoutesFirewallRules = routesFirewallRules
|
|
response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
|
|
|
|
if networkMap.ForwardingRules != nil {
|
|
forwardingRules := make([]*proto.ForwardingRule, 0, len(networkMap.ForwardingRules))
|
|
for _, rule := range networkMap.ForwardingRules {
|
|
forwardingRules = append(forwardingRules, rule.ToProto())
|
|
}
|
|
response.NetworkMap.ForwardingRules = forwardingRules
|
|
}
|
|
|
|
if networkMap.AuthorizedUsers != nil {
|
|
hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers)
|
|
userIDClaim := auth.DefaultUserIDClaim
|
|
if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
|
|
userIDClaim = httpConfig.AuthUserIDClaim
|
|
}
|
|
response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
|
|
}
|
|
|
|
// settings == nil → field stays nil → "no info in this snapshot", client
|
|
// preserves the deadline it already had. settings non-nil → emit either a
|
|
// valid deadline or the explicit-zero "disabled" sentinel via
|
|
// encodeSessionExpiresAt.
|
|
if settings != nil {
|
|
response.SessionExpiresAt = encodeSessionExpiresAt(
|
|
peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration),
|
|
)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
// encodeSessionExpiresAt encodes a server-side deadline into the 3-state wire
|
|
// representation used on LoginResponse, SyncResponse and
|
|
// ExtendAuthSessionResponse. See the proto comments on those messages.
|
|
//
|
|
// - deadline.IsZero() → returns &Timestamp{} (seconds=0, nanos=0): the
|
|
// "expiry disabled or peer is not SSO-tracked" sentinel; the client clears
|
|
// its anchor.
|
|
// - deadline non-zero → returns timestamppb.New(deadline): the new absolute
|
|
// UTC deadline.
|
|
//
|
|
// Returning nil ("no info, preserve client's anchor") is the caller's job —
|
|
// only meaningful on Sync builds where settings were not resolved.
|
|
func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp {
|
|
if deadline.IsZero() {
|
|
return ×tamppb.Timestamp{}
|
|
}
|
|
return timestamppb.New(deadline)
|
|
}
|
|
|
|
func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool {
|
|
if nbversion.IsDevelopmentVersion(peerVersion) {
|
|
return true
|
|
}
|
|
|
|
peerNBVersion, err := version.NewVersion(peerVersion)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion)
|
|
}
|
|
|
|
func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol {
|
|
switch configProto {
|
|
case nbconfig.UDP:
|
|
return proto.HostConfig_UDP
|
|
case nbconfig.DTLS:
|
|
return proto.HostConfig_DTLS
|
|
case nbconfig.HTTP:
|
|
return proto.HostConfig_HTTP
|
|
case nbconfig.HTTPS:
|
|
return proto.HostConfig_HTTPS
|
|
case nbconfig.TCP:
|
|
return proto.HostConfig_TCP
|
|
default:
|
|
panic(fmt.Errorf("unexpected config protocol type %v", configProto))
|
|
}
|
|
}
|
|
|
|
// buildJWTConfig constructs JWT configuration for SSH servers from management server config
|
|
func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig {
|
|
if config == nil || config.AuthAudience == "" {
|
|
return nil
|
|
}
|
|
|
|
issuer := strings.TrimSpace(config.AuthIssuer)
|
|
if issuer == "" && deviceFlowConfig != nil {
|
|
if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" {
|
|
issuer = d
|
|
}
|
|
}
|
|
if issuer == "" {
|
|
return nil
|
|
}
|
|
|
|
keysLocation := strings.TrimSpace(config.AuthKeysLocation)
|
|
if keysLocation == "" {
|
|
keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json"
|
|
}
|
|
|
|
audience := config.AuthAudience
|
|
if config.CLIAuthAudience != "" {
|
|
audience = config.CLIAuthAudience
|
|
}
|
|
|
|
audiences := []string{config.AuthAudience}
|
|
if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
|
|
audiences = append(audiences, config.CLIAuthAudience)
|
|
}
|
|
|
|
return &proto.JWTConfig{
|
|
Issuer: issuer,
|
|
Audience: audience, //nolint:staticcheck
|
|
Audiences: audiences,
|
|
KeysLocation: keysLocation,
|
|
}
|
|
}
|
|
|
|
// deriveIssuerFromTokenEndpoint extracts the issuer URL from a token endpoint
|
|
func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string {
|
|
if tokenEndpoint == "" {
|
|
return ""
|
|
}
|
|
|
|
u, err := url.Parse(tokenEndpoint)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s/", u.Scheme, u.Host)
|
|
}
|