mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-12 02:31:28 +02:00
Compare commits
16 Commits
worktree-a
...
agent-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9267c7d3 | ||
|
|
29eff3b207 | ||
|
|
35137326f7 | ||
|
|
5085a2f96b | ||
|
|
30c2010c09 | ||
|
|
9b06290240 | ||
|
|
829156f53d | ||
|
|
4f6caa1110 | ||
|
|
27b2d3f351 | ||
|
|
ebfdf7d7b8 | ||
|
|
e8671a811d | ||
|
|
1ca26d8faa | ||
|
|
f9abe2727f | ||
|
|
664a3d026c | ||
|
|
d2c961f67c | ||
|
|
5584f8ef0a |
@@ -96,6 +96,7 @@ nfpms:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -119,6 +120,7 @@ nfpms:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
@@ -71,6 +71,7 @@ nfpms:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -95,6 +96,7 @@ nfpms:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk3 or libgtk-3-0)
|
||||
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
@@ -112,6 +114,13 @@ uploads:
|
||||
# The gtk3 packages reuse the netbird-ui package name, so they live in
|
||||
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
|
||||
# legacy distros point their repo config at.
|
||||
#
|
||||
# GoReleaser derives the credential env var from the upload name, so these
|
||||
# would look for UPLOAD_DEBIAN-GTK3_SECRET / UPLOAD_YUM-GTK3_SECRET. The
|
||||
# release workflow only exports UPLOAD_DEBIAN_SECRET / UPLOAD_YUM_SECRET, and
|
||||
# a missing secret is a silent skip rather than a failure -- the packages
|
||||
# reached the GitHub release but never the package repositories. Point
|
||||
# `password` at the exported vars so both uploads authenticate.
|
||||
- name: debian-gtk3
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
@@ -119,6 +128,7 @@ uploads:
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_DEBIAN_SECRET }}"
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
@@ -128,4 +138,5 @@ uploads:
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_YUM_SECRET }}"
|
||||
method: PUT
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -32,6 +33,13 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
@@ -278,8 +286,10 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
cfg, cacheDir, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config from disk
|
||||
@@ -298,6 +308,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
InternalConfig: cfg,
|
||||
StatusRecorder: c.recorder,
|
||||
TempDir: cacheDir,
|
||||
StatePath: platformFiles.StateFilePath(),
|
||||
}
|
||||
|
||||
if cc != nil {
|
||||
@@ -321,6 +332,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package anonymize
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -15,13 +16,88 @@ import (
|
||||
|
||||
const anonTLD = ".domain"
|
||||
|
||||
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
|
||||
// level redacts strictly more. On the wire (protos, flags) levels travel as
|
||||
// their string form.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
|
||||
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
|
||||
// preserved so support can reason about the real topology.
|
||||
LevelDefault Level = iota
|
||||
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys.
|
||||
LevelStrict
|
||||
)
|
||||
|
||||
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
|
||||
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
|
||||
const (
|
||||
LevelDefaultString = "default"
|
||||
LevelStrictString = "strict"
|
||||
)
|
||||
|
||||
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
|
||||
// unrecognized maps to LevelStrict so an unknown request never yields less
|
||||
// anonymization than intended.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "", LevelDefaultString:
|
||||
return LevelDefault
|
||||
default:
|
||||
return LevelStrict
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the wire form of the level: "default" or "strict".
|
||||
func (l Level) String() string {
|
||||
if l >= LevelStrict {
|
||||
return LevelStrictString
|
||||
}
|
||||
return LevelDefaultString
|
||||
}
|
||||
|
||||
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
|
||||
// anonymized bundle. At LevelStrict the labels in front of them (the peer
|
||||
// name) are still replaced, except under netbird.io, which only hosts
|
||||
// NetBird infrastructure (api, signal, flow), never peer names.
|
||||
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
|
||||
|
||||
const infraDomain = "netbird.io"
|
||||
|
||||
var (
|
||||
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
|
||||
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
|
||||
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
|
||||
)
|
||||
|
||||
type Anonymizer struct {
|
||||
ipAnonymizer map[netip.Addr]netip.Addr
|
||||
domainAnonymizer map[string]string
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
// domainOrder caches the keys of domainAnonymizer sorted longest-first
|
||||
// for AnonymizeString; it is rebuilt when the map gains entries.
|
||||
domainOrder []string
|
||||
labelAnonymizer map[string]string
|
||||
labelAnonymized map[string]struct{}
|
||||
labelCounter uint32
|
||||
macAnonymizer map[string]string
|
||||
macCounter uint32
|
||||
wgKeyAnonymizer map[string]string
|
||||
wgKeyAnonymized map[string]struct{}
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
|
||||
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
|
||||
// link-local), replacing them from the dedicated internal pools below so
|
||||
// a reader can still tell an internal address from a public one.
|
||||
level Level
|
||||
currentAnonInternalIPv4 netip.Addr
|
||||
currentAnonInternalIPv6 netip.Addr
|
||||
startAnonInternalIPv4 netip.Addr
|
||||
startAnonInternalIPv6 netip.Addr
|
||||
|
||||
domainKeyRegex *regexp.Regexp
|
||||
}
|
||||
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
|
||||
}
|
||||
|
||||
// InternalAddresses returns the pool starts used in strict mode for internal
|
||||
// ranges. Both are reserved ranges that cannot collide with real addressing:
|
||||
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
|
||||
func InternalAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
|
||||
}
|
||||
|
||||
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
||||
internalIPv4, internalIPv6 := InternalAddresses()
|
||||
return &Anonymizer{
|
||||
ipAnonymizer: map[netip.Addr]netip.Addr{},
|
||||
domainAnonymizer: map[string]string{},
|
||||
labelAnonymizer: map[string]string{},
|
||||
labelAnonymized: map[string]struct{}{},
|
||||
macAnonymizer: map[string]string{},
|
||||
wgKeyAnonymizer: map[string]string{},
|
||||
wgKeyAnonymized: map[string]struct{}{},
|
||||
currentAnonIPv4: startIPv4,
|
||||
currentAnonIPv6: startIPv6,
|
||||
startAnonIPv4: startIPv4,
|
||||
startAnonIPv6: startIPv6,
|
||||
|
||||
level: LevelDefault,
|
||||
currentAnonInternalIPv4: internalIPv4,
|
||||
currentAnonInternalIPv6: internalIPv6,
|
||||
startAnonInternalIPv4: internalIPv4,
|
||||
startAnonInternalIPv6: internalIPv6,
|
||||
|
||||
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel selects the anonymization level. The zero value of a new
|
||||
// Anonymizer is LevelDefault.
|
||||
func (a *Anonymizer) SetLevel(level Level) {
|
||||
a.level = level
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
|
||||
// like 192.168.1.1.
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
(ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast() ||
|
||||
isWellKnown(ip) ||
|
||||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
return ip
|
||||
}
|
||||
|
||||
if isInternal(ip) && a.level < LevelStrict {
|
||||
return ip
|
||||
}
|
||||
|
||||
if _, ok := a.ipAnonymizer[ip]; !ok {
|
||||
if ip.Is4() {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
} else {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
}
|
||||
a.ipAnonymizer[ip] = a.nextAnonIP(ip)
|
||||
}
|
||||
return a.ipAnonymizer[ip]
|
||||
}
|
||||
|
||||
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
|
||||
// At the strict level, internal addresses (including IPv6 ULA, matched
|
||||
// by IsPrivate) come from the internal pools so they remain recognizable
|
||||
// as internal without disclosing the real values.
|
||||
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonInternalIPv4
|
||||
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonInternalIPv6
|
||||
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
|
||||
// locally administered range starting at 02:00:00:00:00:01, at every
|
||||
// anonymization level. Broadcast, multicast, all-zero, and already assigned
|
||||
// placeholder addresses are preserved. The colon and dash spellings of the
|
||||
// same address share one placeholder; the output keeps the input's separator.
|
||||
func (a *Anonymizer) AnonymizeMAC(mac string) string {
|
||||
hw, err := net.ParseMAC(mac)
|
||||
if err != nil || len(hw) != 6 {
|
||||
return mac
|
||||
}
|
||||
|
||||
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
|
||||
return mac
|
||||
}
|
||||
|
||||
key := hw.String()
|
||||
anon, ok := a.macAnonymizer[key]
|
||||
if !ok {
|
||||
a.macCounter++
|
||||
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
|
||||
a.macAnonymizer[key] = anon
|
||||
}
|
||||
|
||||
if strings.Contains(mac, "-") {
|
||||
anon = strings.ReplaceAll(anon, ":", "-")
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
|
||||
// handed out, so a second pass over anonymized output leaves it unchanged.
|
||||
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
|
||||
return false
|
||||
}
|
||||
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
|
||||
return value <= a.macCounter
|
||||
}
|
||||
|
||||
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
|
||||
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) AnonymizeWGKey(key string) string {
|
||||
if a.level < LevelStrict || !looksLikeWGKey(key) {
|
||||
return key
|
||||
}
|
||||
if _, ok := a.wgKeyAnonymized[key]; ok {
|
||||
return key
|
||||
}
|
||||
|
||||
anon, ok := a.wgKeyAnonymizer[key]
|
||||
if !ok {
|
||||
anon = generateAnonymousKey()
|
||||
a.wgKeyAnonymizer[key] = anon
|
||||
a.wgKeyAnonymized[anon] = struct{}{}
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
// Convert IP to netip.Addr
|
||||
ip, ok := netip.AddrFromSlice(addr.IP)
|
||||
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
|
||||
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
|
||||
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
|
||||
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
|
||||
return true
|
||||
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
|
||||
return true
|
||||
if ip.Is4() {
|
||||
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
|
||||
}
|
||||
return false
|
||||
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||
@@ -118,14 +301,17 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
baseDomain = domain[:len(domain)-1]
|
||||
}
|
||||
|
||||
if strings.HasSuffix(baseDomain, "netbird.io") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.stage") ||
|
||||
strings.HasSuffix(baseDomain, anonTLD) {
|
||||
if strings.HasSuffix(baseDomain, anonTLD) {
|
||||
return domain
|
||||
}
|
||||
|
||||
if suffix := protectedSuffix(baseDomain); suffix != "" {
|
||||
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
|
||||
return domain
|
||||
}
|
||||
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDomain, ".")
|
||||
if len(parts) < 2 {
|
||||
return domain
|
||||
@@ -141,12 +327,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
}
|
||||
|
||||
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
|
||||
if hasDot {
|
||||
result += "."
|
||||
if a.level >= LevelStrict && len(parts) > 2 {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
|
||||
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
|
||||
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
|
||||
// in log lines as a whole, labels included.
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return withTrailingDot(result, hasDot)
|
||||
}
|
||||
|
||||
// anonymizePeerName replaces the labels in front of a protected suffix with
|
||||
// numbered peer placeholders, keeping the suffix, and records the full
|
||||
// mapping for string replacement in logs. The numbering keeps a peer
|
||||
// recognizable across the whole bundle without disclosing its name.
|
||||
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
|
||||
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
|
||||
if result != baseDomain {
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// anonymizeLabels replaces each dot-separated label with a consistent
|
||||
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
|
||||
labels := strings.Split(prefix, ".")
|
||||
for i, label := range labels {
|
||||
if label == "*" {
|
||||
continue
|
||||
}
|
||||
if _, ok := a.labelAnonymized[label]; ok {
|
||||
continue
|
||||
}
|
||||
anon, ok := a.labelAnonymizer[label]
|
||||
if !ok {
|
||||
a.labelCounter++
|
||||
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
|
||||
a.labelAnonymizer[label] = anon
|
||||
a.labelAnonymized[anon] = struct{}{}
|
||||
}
|
||||
labels[i] = anon
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeURI(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
@@ -181,16 +408,70 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
|
||||
for domain, anonDomain := range a.domainAnonymizer {
|
||||
str = strings.ReplaceAll(str, domain, anonDomain)
|
||||
for _, domain := range a.sortedDomains() {
|
||||
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
|
||||
}
|
||||
|
||||
str = a.AnonymizeSchemeURI(str)
|
||||
str = a.AnonymizeDNSLogLine(str)
|
||||
|
||||
// MAC handling runs after the IP passes so preserved IPv6 addresses are
|
||||
// already out of the way; the separator guard skips matches embedded in a
|
||||
// longer colon- or dash-separated sequence (such as an IPv6 tail).
|
||||
str = a.anonymizeMACsInString(str, macColonRegex, ':')
|
||||
str = a.anonymizeMACsInString(str, macDashRegex, '-')
|
||||
|
||||
if a.level >= LevelStrict {
|
||||
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
// mapping (strict level) is applied before the base-domain mapping it
|
||||
// contains. The order is rebuilt only when domainAnonymizer has grown.
|
||||
func (a *Anonymizer) sortedDomains() []string {
|
||||
if len(a.domainOrder) == len(a.domainAnonymizer) {
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
a.domainOrder = a.domainOrder[:0]
|
||||
for domain := range a.domainAnonymizer {
|
||||
a.domainOrder = append(a.domainOrder, domain)
|
||||
}
|
||||
slices.SortFunc(a.domainOrder, func(x, y string) int {
|
||||
if d := len(y) - len(x); d != 0 {
|
||||
return d
|
||||
}
|
||||
return strings.Compare(x, y)
|
||||
})
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
|
||||
// matches that directly adjoin another sep so a six-group run inside a longer
|
||||
// separated sequence is left alone.
|
||||
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
|
||||
matches := re.FindAllStringIndex(str, -1)
|
||||
if len(matches) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range matches {
|
||||
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(str[last:m[0]])
|
||||
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
|
||||
last = m[1]
|
||||
}
|
||||
b.WriteString(str[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
|
||||
func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
|
||||
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
|
||||
@@ -239,10 +520,79 @@ func isWellKnown(addr netip.Addr) bool {
|
||||
"128.0.0.0", "8000::", // 2nd split subnet for default routes
|
||||
}
|
||||
|
||||
if slices.Contains(wellKnown, addr.String()) {
|
||||
return slices.Contains(wellKnown, addr.String())
|
||||
}
|
||||
|
||||
// isInternal reports whether ip identifies a host only within the local
|
||||
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
|
||||
// v6). These are preserved at the default level so support can reason about
|
||||
// the real topology, and replaced from the internal pools at the strict
|
||||
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
|
||||
// fingerprints the network, so it is anonymized at every level.
|
||||
func isInternal(ip netip.Addr) bool {
|
||||
return (ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
isCGNAT(ip)
|
||||
}
|
||||
|
||||
func inPoolRange(ip, start, current netip.Addr) bool {
|
||||
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
|
||||
}
|
||||
|
||||
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
|
||||
// or a group address (broadcast and multicast).
|
||||
func isWellKnownMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0]&1 == 1 {
|
||||
return true
|
||||
}
|
||||
for _, b := range hw {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
|
||||
// 44 base64 characters decoding to 32 bytes.
|
||||
func looksLikeWGKey(s string) bool {
|
||||
if len(s) != 44 || s[43] != '=' {
|
||||
return false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func generateAnonymousKey() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strings.Repeat("A", 43) + "="
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
|
||||
// or empty. The match is label-anchored so an unrelated domain that merely
|
||||
// ends in the same characters is not preserved.
|
||||
func protectedSuffix(baseDomain string) string {
|
||||
for _, d := range protectedDomains {
|
||||
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withTrailingDot(domain string, hasDot bool) string {
|
||||
if hasDot {
|
||||
return domain + "."
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
|
||||
// NetBird assigns overlay peer addresses from.
|
||||
func isCGNAT(addr netip.Addr) bool {
|
||||
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
|
||||
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package anonymize_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expect anonymize.Level
|
||||
}{
|
||||
{"", anonymize.LevelDefault},
|
||||
{"default", anonymize.LevelDefault},
|
||||
{"DEFAULT", anonymize.LevelDefault},
|
||||
{"strict", anonymize.LevelStrict},
|
||||
{"STRICT", anonymize.LevelStrict},
|
||||
// Unknown values must never yield less anonymization than requested.
|
||||
{"garbage", anonymize.LevelStrict},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run("input="+tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
|
||||
{"CGNAT", "100.64.0.5", "100.64.0.5"},
|
||||
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
|
||||
{"IPv6 link-local", "fe80::1", "fe80::1"},
|
||||
// ULA is anonymized even at the default level: its random global ID
|
||||
// uniquely fingerprints the network, unlike shared RFC 1918 space.
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
|
||||
// 4-in-6 addresses classify like their unmapped IPv4 form.
|
||||
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
|
||||
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Order matters: internal pool addresses are assigned sequentially.
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
|
||||
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
|
||||
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
|
||||
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
|
||||
{"CGNAT", "100.64.0.5", "198.18.0.4"},
|
||||
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
|
||||
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
|
||||
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
|
||||
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
|
||||
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
|
||||
{"Loopback IPv6", "::1", "::1"},
|
||||
{"Unspecified", "0.0.0.0", "0.0.0.0"},
|
||||
{"Multicast", "224.0.0.251", "224.0.0.251"},
|
||||
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
|
||||
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
|
||||
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
|
||||
{"In public pool range", "198.51.100.0", "198.51.100.0"},
|
||||
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
secondPass := anonymizer.AnonymizeString(firstPass)
|
||||
|
||||
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
|
||||
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
|
||||
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
|
||||
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
|
||||
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
|
||||
}
|
||||
|
||||
func TestAnonymizeMAC(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
|
||||
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
|
||||
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
|
||||
|
||||
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
|
||||
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
}{
|
||||
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
|
||||
{"IPv4 multicast", "01:00:5e:00:00:fb"},
|
||||
{"IPv6 multicast", "33:33:00:00:00:01"},
|
||||
{"All zero", "00:00:00:00:00:00"},
|
||||
{"Assigned placeholder", "02:00:00:00:00:01"},
|
||||
{"Invalid", "not-a-mac"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_MACAddresses(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "nftables ether rule",
|
||||
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
|
||||
expect: "ether saddr 02:00:00:00:00:01 drop",
|
||||
},
|
||||
{
|
||||
name: "Windows dash form",
|
||||
input: "Physical Address : AA-BB-CC-DD-EE-FF",
|
||||
expect: "Physical Address : 02-00-00-00-00-01",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address tail is not treated as MAC",
|
||||
input: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
},
|
||||
{
|
||||
name: "broadcast MAC preserved",
|
||||
input: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeString(tc.input)
|
||||
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeWGKey(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
anon := anonymizer.AnonymizeWGKey(key)
|
||||
assert.NotEqual(t, key, anon, "strict level should replace the key")
|
||||
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
|
||||
|
||||
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeString_WGKeys(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
input := "peer " + key + " handshake completed"
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
|
||||
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
|
||||
func extractKey(t *testing.T, logLine string) string {
|
||||
t.Helper()
|
||||
fields := strings.Fields(logLine)
|
||||
require.Len(t, fields, 4, "log line should keep its structure")
|
||||
return fields[1]
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
t.Run("netbird peer name", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
|
||||
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
|
||||
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
|
||||
})
|
||||
|
||||
t.Run("bare netbird domain", func(t *testing.T) {
|
||||
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
|
||||
})
|
||||
|
||||
t.Run("netbird infrastructure preserved", func(t *testing.T) {
|
||||
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
|
||||
"netbird.io hosts infrastructure, not peer names, and should stay readable")
|
||||
})
|
||||
|
||||
t.Run("leading labels of other domains", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
|
||||
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
|
||||
for _, label := range []string{"host1", "corp", "example"} {
|
||||
assert.NotContains(t, result, label, "no original label should survive")
|
||||
}
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
|
||||
})
|
||||
|
||||
t.Run("same label maps consistently across domains", func(t *testing.T) {
|
||||
first := anonymizer.AnonymizeDomain("shared.one.com")
|
||||
second := anonymizer.AnonymizeDomain("shared.two.com")
|
||||
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
|
||||
})
|
||||
|
||||
t.Run("wildcard label preserved", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("*.example.com")
|
||||
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
|
||||
"default level should preserve netbird FQDNs including the peer name")
|
||||
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
|
||||
"default level should keep subdomain labels")
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Seed like the bundle generator does from the status: base first, then
|
||||
// the full FQDN, so replacement must prefer the longer mapping.
|
||||
anonBase := anonymizer.AnonymizeDomain("example.com")
|
||||
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
|
||||
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
|
||||
|
||||
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
|
||||
firstPass := anonymizer.AnonymizeString(logLine)
|
||||
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
|
||||
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
|
||||
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
|
||||
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
|
||||
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
}
|
||||
|
||||
func TestAnonymizeDNSLogLine(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
|
||||
tests := []struct {
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
|
||||
var (
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
uploadBundleFlag bool
|
||||
uploadBundleURLFlag string
|
||||
uploadBundleInsecureFlag bool
|
||||
@@ -156,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,10 +173,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -229,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid duration format: %v", err)
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,10 +379,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
cmd.Println("Creating debug bundle...")
|
||||
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ var (
|
||||
autoConnectDisabled bool
|
||||
extraIFaceBlackList []string
|
||||
anonymizeFlag bool
|
||||
anonymizeLevelFlag string
|
||||
dnsRouteInterval time.Duration
|
||||
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
|
||||
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
|
||||
@@ -156,7 +158,8 @@ func init() {
|
||||
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
|
||||
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
|
||||
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
|
||||
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
|
||||
|
||||
rootCmd.AddCommand(upCmd)
|
||||
@@ -293,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
|
||||
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
|
||||
// setting a level implies anonymization, and an invalid level is rejected.
|
||||
func effectiveAnonymize() (bool, anonymize.Level, error) {
|
||||
if anonymizeLevelFlag == "" {
|
||||
return anonymizeFlag, anonymize.LevelDefault, nil
|
||||
}
|
||||
level := anonymize.ParseLevel(anonymizeLevelFlag)
|
||||
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
|
||||
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
|
||||
}
|
||||
return true, level, nil
|
||||
}
|
||||
|
||||
func getSetupKey() (string, error) {
|
||||
if setupKeyPath != "" && setupKey == "" {
|
||||
return getSetupKeyFromFile(setupKeyPath)
|
||||
|
||||
@@ -121,8 +121,14 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
sessionExpiresAt = ts.AsTime().UTC()
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||
Anonymize: anonymizeFlag,
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel,
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||
StatusFilter: statusFilter,
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
//go:build privileged
|
||||
|
||||
package iptables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func iptRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func iptRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newIptRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = iptRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = iptRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func iptDnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func iptDnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
|
||||
func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newIptRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV4(7082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
|
||||
// decrements back to zero.
|
||||
func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9081))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV6(9082))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
|
||||
// without bumping the refcount.
|
||||
func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := iptDnatV4(7083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err)
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
|
||||
// neither errors nor releases the refcount.
|
||||
func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
phantom := iptDnatV4(7099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
phantom6 := iptDnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7100))
|
||||
require.NoError(t, err)
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
|
||||
// rule is a no-op.
|
||||
func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9083))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
|
||||
}
|
||||
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// Forwarding refcounter is per-family but shared between v4 and v6 routers.
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
|
||||
@@ -402,12 +402,17 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDNATRule adds a DNAT rule
|
||||
|
||||
@@ -102,7 +102,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
|
||||
wgIface: wgIface,
|
||||
mtu: mtu,
|
||||
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
}
|
||||
|
||||
r.ipsetCounter = refcounter.New(
|
||||
@@ -770,6 +770,10 @@ func (r *router) updateState() {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -836,34 +840,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
|
||||
for key, ruleInfo := range rules {
|
||||
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
if rollbackErr := r.rollbackRules(rules); rollbackErr != nil {
|
||||
log.Errorf("rollback failed: %v", rollbackErr)
|
||||
}
|
||||
return nil, fmt.Errorf("add rule %s: %w", key, err)
|
||||
}
|
||||
r.rules[key] = ruleInfo.rule
|
||||
}
|
||||
|
||||
if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
|
||||
// AddDNATRule before rolling back the kernel rules, so no entries remain that
|
||||
// never got a forwarding refcount. rollbackRules re-adds entries it failed to
|
||||
// remove from the kernel.
|
||||
func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) {
|
||||
for key := range rules {
|
||||
delete(r.rules, key)
|
||||
}
|
||||
if err := r.rollbackRules(rules); err != nil {
|
||||
log.Errorf("rollback failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
var merr *multierror.Error
|
||||
for key, ruleInfo := range rules {
|
||||
@@ -880,47 +868,32 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
ruleKey := rule.ID()
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
_, hadFWD := r.rules[ruleKey+fwdSuffix]
|
||||
if !hadDNAT && !hadSNAT && !hadFWD {
|
||||
return nil
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
var merr *multierror.Error
|
||||
if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
|
||||
if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
|
||||
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once all rules are gone from the kernel. On
|
||||
// partial failure the failed entries stay in r.rules so a retry can remove
|
||||
// them and release then.
|
||||
if merr == nil {
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
//go:build privileged
|
||||
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func nftRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nftRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newNftRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
if check() != NFTABLES {
|
||||
t.Skip("nftables not supported on this system")
|
||||
}
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = nftRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = nftRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func dnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func dnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
|
||||
// v4 refcount at zero.
|
||||
func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newNftRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV4(8081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV4(8082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
|
||||
// and decrements back to zero on Delete.
|
||||
func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9091))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV6(9092))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
|
||||
// ForwardRule) does not double-increment the refcount.
|
||||
func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := dnatV4(8083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "add v4 dnat")
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
// duplicate add: same rule ID, must be a no-op for the refcount.
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
|
||||
// never added does not underflow the refcount.
|
||||
func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
// Construct a Rule reference for something never added. The router stores
|
||||
// rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
|
||||
// entry must be a no-op rather than calling Release.
|
||||
phantom := dnatV4(8099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected")
|
||||
|
||||
phantom6 := dnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
|
||||
|
||||
// And after a phantom delete, a real add still results in count=1.
|
||||
r1, err := m.AddDNATRule(dnatV4(8100))
|
||||
require.NoError(t, err, "add v4 dnat after phantom delete")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
|
||||
// twice does not underflow the refcount (the second delete is a no-op).
|
||||
func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9093))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt
|
||||
return fmt.Errorf("create v6 router: %w", err)
|
||||
}
|
||||
|
||||
// Share the per-family forwarding refcounter with the v4 router so a v4
|
||||
// rule and a v6 rule against the same state machine cooperate cleanly.
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
|
||||
@@ -530,12 +530,17 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush rule/chain/set operations from the buffer
|
||||
|
||||
@@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou
|
||||
rules: make(map[string]*nftables.Rule),
|
||||
af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4),
|
||||
wgIface: wgIface,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
mtu: mtu,
|
||||
}
|
||||
|
||||
@@ -1553,6 +1553,10 @@ func (r *router) refreshRulesMap() error {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -1563,18 +1567,7 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
return nil, fmt.Errorf("convert protocol to number: %w", err)
|
||||
}
|
||||
|
||||
// Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
|
||||
// buffer netlink messages on r.conn that the next caller's Flush would
|
||||
// commit if we returned without flushing them ourselves.
|
||||
v6 := r.af.tableFamily == nftables.TableFamilyIPv6
|
||||
if err := r.ipFwdState.RequestForwarding(v6); err != nil {
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1586,11 +1579,6 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
// TODO: find chains with drop policies and add rules there
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
return nil, fmt.Errorf("flush rules: %w", err)
|
||||
}
|
||||
|
||||
@@ -1793,18 +1781,16 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
if !hadDNAT && !hadSNAT {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
var needsFlush bool
|
||||
|
||||
@@ -1836,16 +1822,9 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once the rules are gone from the kernel. On
|
||||
// failure (including the refreshRulesMap error above) the rules and their
|
||||
// map entries remain, keeping forwarding on until a retry removes them.
|
||||
if merr == nil {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
|
||||
@@ -22,6 +22,16 @@ import (
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
const (
|
||||
// wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
|
||||
wgMsgTypeHandshakeInitiation uint32 = 1
|
||||
// wgMsgTypeTransport is the highest WireGuard message type.
|
||||
wgMsgTypeTransport uint32 = 4
|
||||
// wgMinMsgSize is the smallest WireGuard message: transport data with an empty
|
||||
// payload, which is what a keepalive is.
|
||||
wgMinMsgSize = 32
|
||||
)
|
||||
|
||||
type receiverCreator struct {
|
||||
iceBind *ICEBind
|
||||
}
|
||||
@@ -216,8 +226,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
for i := 0; i < numMsgs; i++ {
|
||||
msg := &(*msgs)[i]
|
||||
|
||||
// todo: handle err
|
||||
if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if err != nil {
|
||||
log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
|
||||
}
|
||||
// WireGuard reuses sizes and eps across reads and only skips a slot
|
||||
// whose size is below the minimum message size. Leaving a consumed
|
||||
// slot untouched makes it process this buffer again under the
|
||||
// previous packet's length and endpoint.
|
||||
sizes[i] = 0
|
||||
continue
|
||||
}
|
||||
sizes[i] = msg.N
|
||||
@@ -271,11 +288,16 @@ func (s *ICEBind) createOrUpdateMux() {
|
||||
|
||||
func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) {
|
||||
for i := range buffers {
|
||||
if !stun.IsMessage(buffers[i]) {
|
||||
if n > len(buffers[i]) {
|
||||
continue
|
||||
}
|
||||
pkt := buffers[i][:n]
|
||||
|
||||
if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := s.parseSTUNMessage(buffers[i][:n])
|
||||
msg, err := s.parseSTUNMessage(pkt)
|
||||
if err != nil {
|
||||
buffers[i] = []byte{}
|
||||
return true, err
|
||||
@@ -347,18 +369,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
|
||||
msgsPool.Put(msgs)
|
||||
}
|
||||
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
// The first buffer should contain at least 4 bytes for type
|
||||
if len(buffers[0]) < 4 {
|
||||
return true
|
||||
// isWireGuardMsg reports whether the packet carries a WireGuard message header: a
|
||||
// little-endian uint32 message type in the range 1..4, which leaves the three bytes
|
||||
// after the type byte zero, in a packet long enough to hold any WireGuard message.
|
||||
//
|
||||
// A well formed STUN message cannot take that shape. Its length field sits in the two
|
||||
// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that
|
||||
// field holds at least 12, so the two framings do not overlap. The test has to be this
|
||||
// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard
|
||||
// message overlaps the receiver index: a session whose index happens to equal the cookie
|
||||
// would otherwise have all of its inbound data misrouted to the STUN handler until the
|
||||
// next rekey.
|
||||
func isWireGuardMsg(pkt []byte) bool {
|
||||
if len(pkt) < wgMinMsgSize {
|
||||
return false
|
||||
}
|
||||
|
||||
// WireGuard packet type is a little-endian uint32 at start
|
||||
packetType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
|
||||
// Check if packetType matches known WireGuard message types
|
||||
if packetType == 4 && n > 32 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
msgType := binary.LittleEndian.Uint32(pkt[:4])
|
||||
return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport
|
||||
}
|
||||
|
||||
// isTransportPkg reports whether the packet is WireGuard transport data carrying a
|
||||
// payload, which is what counts as peer activity. A keepalive holds no payload and is
|
||||
// exactly wgMinMsgSize bytes.
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
if n < 4 || n > len(buffers[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
msgType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
return msgType == wgMsgTypeTransport && n > wgMinMsgSize
|
||||
}
|
||||
|
||||
215
client/iface/bind/stun_filter_test.go
Normal file
215
client/iface/bind/stun_filter_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
//go:build !js
|
||||
|
||||
package bind
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/ipv4"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
)
|
||||
|
||||
// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
|
||||
// WireGuard message the same offset holds the receiver (or sender) index, which is
|
||||
// a random uint32, so a session can draw exactly this value.
|
||||
var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42}
|
||||
|
||||
const testBufSize = 1500
|
||||
|
||||
// wgMsg builds a WireGuard message of the given type and size, with the index field
|
||||
// at bytes 4:8 set to index.
|
||||
func wgMsg(msgType uint32, size int, index []byte) []byte {
|
||||
pkt := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(pkt[:4], msgType)
|
||||
copy(pkt[4:8], index)
|
||||
return pkt
|
||||
}
|
||||
|
||||
// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read
|
||||
// does, so tests see the same buffer/length split as the hot path.
|
||||
func intoBuffer(pkt []byte) [][]byte {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf, pkt)
|
||||
return [][]byte{buf}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgType uint32
|
||||
size int
|
||||
}{
|
||||
{"transport data", wgMsgTypeTransport, 128},
|
||||
{"keepalive", wgMsgTypeTransport, wgMinMsgSize},
|
||||
{"handshake initiation", wgMsgTypeHandshakeInitiation, 148},
|
||||
{"handshake response", 2, 92},
|
||||
{"cookie reply", 3, 64},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes)
|
||||
require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN")
|
||||
|
||||
buffers := intoBuffer(pkt)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler")
|
||||
assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) {
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed by the STUN handler")
|
||||
assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it")
|
||||
}
|
||||
|
||||
// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a
|
||||
// well formed STUN message long enough to be a WireGuard message always has a
|
||||
// non-zero length field, so it cannot be mistaken for a WireGuard header.
|
||||
func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) {
|
||||
types := []stun.MessageType{
|
||||
stun.BindingRequest,
|
||||
stun.BindingSuccess,
|
||||
stun.BindingError,
|
||||
{Method: stun.MethodBinding, Class: stun.ClassIndication},
|
||||
}
|
||||
|
||||
for _, msgType := range types {
|
||||
// Long enough that the length guard is not what makes this pass.
|
||||
msg, err := stun.Build(msgType, stun.TransactionID,
|
||||
stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType)
|
||||
assert.False(t, isWireGuardMsg(msg.Raw),
|
||||
"%s must not look like a WireGuard message", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWireGuardMsg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
want bool
|
||||
}{
|
||||
{"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true},
|
||||
{"unknown type 5", wgMsg(5, 128, nil), false},
|
||||
{"type 0", wgMsg(0, 128, nil), false},
|
||||
{"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false},
|
||||
{"too short", []byte{0x04, 0x00, 0x00}, false},
|
||||
{"empty", nil, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on
|
||||
// buffer contents left over from an earlier, longer packet.
|
||||
func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf[4:8], magicCookieBytes)
|
||||
buffers := [][]byte{buf}
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes")
|
||||
}
|
||||
|
||||
// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies
|
||||
// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must
|
||||
// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the
|
||||
// previous packet's length, which for a WireGuard-shaped packet means it is handled
|
||||
// twice.
|
||||
func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
|
||||
conn := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer conn.Close()
|
||||
|
||||
recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn(
|
||||
ipv4.NewPacketConn(conn), conn, false, createMsgPool(),
|
||||
)
|
||||
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer sender.Close()
|
||||
_, err = sender.WriteTo(msg.Raw, conn.LocalAddr())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second)))
|
||||
|
||||
bufs := [][]byte{make([]byte, 1500)}
|
||||
// A leftover size from an earlier read, which is what makes the missing reset
|
||||
// observable.
|
||||
sizes := []int{148}
|
||||
eps := make([]wgConn.Endpoint, 1)
|
||||
|
||||
n, err := recvFn(bufs, sizes, eps)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard")
|
||||
}
|
||||
|
||||
func TestIsTransportPkg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
n int
|
||||
want bool
|
||||
}{
|
||||
{"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true},
|
||||
{"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false},
|
||||
{"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n),
|
||||
"wrong activity classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN
|
||||
// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a
|
||||
// request encodes to 0x0200, so the type byte reads as a handshake response and the byte
|
||||
// after it is zero. Only the length check keeps such a message out of WireGuard's hands.
|
||||
// pion implements no method in that range, so this is a synthetic worst case rather than
|
||||
// traffic ICE produces.
|
||||
func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) {
|
||||
msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4],
|
||||
"precondition: the leading bytes read as a WireGuard message type")
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type")
|
||||
}
|
||||
@@ -34,9 +34,8 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
const readmeContent = `Netbird debug bundle
|
||||
This debug bundle contains the following files.
|
||||
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
|
||||
const readmeContent = `This debug bundle contains the following files.
|
||||
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
|
||||
|
||||
status.txt: Anonymized status information of the NetBird client.
|
||||
client.log: Most recent, anonymized client log file of the NetBird client.
|
||||
@@ -70,21 +69,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
|
||||
|
||||
|
||||
Anonymization Process
|
||||
The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
|
||||
The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
|
||||
|
||||
IP Addresses
|
||||
|
||||
IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
IPv6 addresses are replaced with addresses starting from 100::
|
||||
Default level:
|
||||
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
|
||||
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
|
||||
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
|
||||
|
||||
Strict level (--anonymize-level strict), in addition to the default level:
|
||||
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
|
||||
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
|
||||
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
|
||||
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
|
||||
- WireGuard public keys are replaced with consistent placeholder keys.
|
||||
|
||||
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
|
||||
Reoccuring IP addresses are replaced with the same anonymized address.
|
||||
|
||||
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
|
||||
|
||||
MAC Addresses
|
||||
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
|
||||
|
||||
Domains
|
||||
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
|
||||
Reoccuring domain names are replaced with the same anonymized domain.
|
||||
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
|
||||
|
||||
Sync Response
|
||||
The network_map.json file contains the following anonymized information:
|
||||
@@ -281,6 +293,7 @@ type BundleGenerator struct {
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
anonymizeLevel anonymize.Level
|
||||
includeSystemInfo bool
|
||||
logFileCount uint32
|
||||
|
||||
@@ -288,7 +301,10 @@ type BundleGenerator struct {
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Anonymize bool
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts.
|
||||
// anonymize.LevelStrict implies Anonymize.
|
||||
AnonymizeLevel anonymize.Level
|
||||
IncludeSystemInfo bool
|
||||
LogFileCount uint32
|
||||
}
|
||||
@@ -327,8 +343,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
uiLogOpener = openLogFile
|
||||
}
|
||||
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(cfg.AnonymizeLevel)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
anonymizer: anonymizer,
|
||||
|
||||
internalConfig: deps.InternalConfig,
|
||||
statusRecorder: deps.StatusRecorder,
|
||||
@@ -345,7 +364,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
|
||||
anonymizeLevel: cfg.AnonymizeLevel,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
@@ -485,7 +505,13 @@ func (g *BundleGenerator) addSystemInfo() {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addReadme() error {
|
||||
readmeReader := strings.NewReader(readmeContent)
|
||||
level := "none (anonymization disabled)"
|
||||
if g.anonymize {
|
||||
level = g.anonymizeLevel.String()
|
||||
}
|
||||
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
|
||||
|
||||
readmeReader := strings.NewReader(header + readmeContent)
|
||||
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
|
||||
return fmt.Errorf("add README file to zip: %w", err)
|
||||
}
|
||||
@@ -507,9 +533,10 @@ func (g *BundleGenerator) addStatus() error {
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
Anonymize: g.anonymize,
|
||||
AnonymizeLevel: g.anonymizeLevel,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
@@ -662,7 +689,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||
|
||||
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
|
||||
}
|
||||
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
|
||||
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
|
||||
@@ -952,6 +979,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
|
||||
}
|
||||
|
||||
baseName := filepath.Base(logFile)
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping update log file %s: %v", baseName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
|
||||
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
|
||||
}
|
||||
@@ -979,6 +1011,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
}
|
||||
|
||||
fileName := filepath.Base(match)
|
||||
// Corrupted state files usually fail structured JSON anonymization,
|
||||
// so run them through the string anonymizer instead.
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
|
||||
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
|
||||
continue
|
||||
@@ -990,6 +1029,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anonymizeBytes runs raw file content through the string anonymizer line by
|
||||
// line when anonymization is enabled. It errors instead of returning partial
|
||||
// content, so a caller never adds an unanonymized fallback to the bundle.
|
||||
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
|
||||
if !g.anonymize {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("anonymize content: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addMetrics() error {
|
||||
if g.clientMetrics == nil {
|
||||
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||
@@ -1462,6 +1522,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
|
||||
}
|
||||
|
||||
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
|
||||
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
|
||||
|
||||
anonymizeSSHConfig(peer.SshConfig)
|
||||
}
|
||||
|
||||
@@ -844,10 +844,6 @@ func collectSysctls() string {
|
||||
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
|
||||
listInterfaceSysctls("ipv4", "src_valid_mark")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "accept_ra", append(
|
||||
[]string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"},
|
||||
listInterfaceSysctls("ipv6", "accept_ra")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "conntrack", []string{
|
||||
"net.netfilter.nf_conntrack_acct",
|
||||
"net.netfilter.nf_conntrack_tcp_loose",
|
||||
|
||||
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey)))
|
||||
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
|
||||
if s.FWMark != 0 {
|
||||
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
|
||||
}
|
||||
|
||||
for _, peer := range s.Peers {
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
|
||||
if peer.Endpoint.IP != nil {
|
||||
if g.anonymize {
|
||||
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
|
||||
@@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
if len(peer.AllowedIPs) > 0 {
|
||||
var ipStrings []string
|
||||
for _, ipnet := range peer.AllowedIPs {
|
||||
ipStrings = append(ipStrings, ipnet.String())
|
||||
ipStr := ipnet.String()
|
||||
if g.anonymize {
|
||||
ipStr = g.anonymizer.AnonymizeIPString(ipStr)
|
||||
}
|
||||
ipStrings = append(ipStrings, ipStr)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
@@ -1385,6 +1386,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
|
||||
bundleJobParams := debug.BundleConfig{
|
||||
Anonymize: params.Anonymize,
|
||||
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
LogFileCount: uint32(params.LogFileCount),
|
||||
}
|
||||
|
||||
@@ -2,183 +2,54 @@ package ipfwdstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
||||
)
|
||||
|
||||
// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with
|
||||
// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls.
|
||||
// IPForwardingState is a struct that keeps track of the IP forwarding state.
|
||||
// todo: read initial state of the IP forwarding from the system and reset the state based on it.
|
||||
// todo: separate v4/v6 forwarding state, since the sysctls are independent
|
||||
// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables
|
||||
// manager shares one instance between both routers, which works only because
|
||||
// EnableIPForwarding enables both sysctls in a single call.
|
||||
type IPForwardingState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
v4Count int
|
||||
v6Count int
|
||||
|
||||
// routingV4/routingV6 track whether the routing path currently holds a
|
||||
// reference, so repeated EnableRouting calls (one per network-map update)
|
||||
// hold at most one reference per family and an unpaired DisableRouting
|
||||
// can't release references held by DNAT rules.
|
||||
routingV4 bool
|
||||
routingV6 bool
|
||||
|
||||
wgIfaceName string
|
||||
v6Saved map[string]int
|
||||
enabledCounter int
|
||||
}
|
||||
|
||||
// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls.
|
||||
// wgIfaceName is excluded from the per-interface accept_ra handling.
|
||||
func NewIPForwardingState(wgIfaceName string) *IPForwardingState {
|
||||
return &IPForwardingState{wgIfaceName: wgIfaceName}
|
||||
func NewIPForwardingState() *IPForwardingState {
|
||||
return &IPForwardingState{}
|
||||
}
|
||||
|
||||
// Counts returns the current v4 and v6 refcounts. Intended for diagnostics
|
||||
// and tests.
|
||||
func (f *IPForwardingState) Counts() (v4, v6 int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.v4Count, f.v6Count
|
||||
}
|
||||
|
||||
// RequestRouting takes the forwarding references for the routing path. It is
|
||||
// idempotent: while routing already holds a reference, further calls don't
|
||||
// increment the refcounts, and a v4-only request releases a previously held v6
|
||||
// reference. A v6 sysctl failure is logged and not returned so it can't take
|
||||
// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or
|
||||
// IPv6 disabled on the kernel command line); v6 is retried on the next call.
|
||||
func (f *IPForwardingState) RequestRouting(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if !f.routingV4 {
|
||||
if err := f.requestV4(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.routingV4 = true
|
||||
}
|
||||
|
||||
if !v6 {
|
||||
if !f.routingV6 {
|
||||
return nil
|
||||
}
|
||||
f.routingV6 = false
|
||||
return f.releaseV6()
|
||||
}
|
||||
|
||||
if f.routingV6 {
|
||||
return nil
|
||||
}
|
||||
if err := f.requestV6(); err != nil {
|
||||
log.Warnf("enable IPv6 forwarding for routing: %v", err)
|
||||
return nil
|
||||
}
|
||||
f.routingV6 = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseRouting releases the references RequestRouting holds. Calls without a
|
||||
// held reference are no-ops.
|
||||
func (f *IPForwardingState) ReleaseRouting() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.routingV4 {
|
||||
f.routingV4 = false
|
||||
f.releaseV4()
|
||||
}
|
||||
if f.routingV6 {
|
||||
f.routingV6 = false
|
||||
return f.releaseV6()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestForwarding enables the family's forwarding sysctl on first request.
|
||||
func (f *IPForwardingState) RequestForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.requestV6()
|
||||
}
|
||||
return f.requestV4()
|
||||
}
|
||||
|
||||
// ReleaseForwarding decrements the family counter. The last v6 release restores
|
||||
// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other
|
||||
// tooling (docker, k8s, libvirt).
|
||||
func (f *IPForwardingState) ReleaseForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.releaseV6()
|
||||
}
|
||||
f.releaseV4()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) requestV4() error {
|
||||
if f.v4Count == 0 {
|
||||
if err := systemops.EnableV4IPForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IPv4 forwarding: %w", err)
|
||||
}
|
||||
log.Info("IPv4 forwarding enabled")
|
||||
}
|
||||
f.v4Count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) releaseV4() {
|
||||
if f.v4Count > 0 {
|
||||
f.v4Count--
|
||||
}
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) requestV6() error {
|
||||
if f.v6Count == 0 {
|
||||
saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName)
|
||||
if err != nil {
|
||||
if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil {
|
||||
log.Warnf("rollback partial v6 sysctls: %v", rerr)
|
||||
}
|
||||
return fmt.Errorf("enable IPv6 forwarding: %w", err)
|
||||
}
|
||||
// A failed restore on a previous release keeps its saved values; those
|
||||
// are the true originals, so keep them over what this enable captured.
|
||||
if f.v6Saved == nil {
|
||||
f.v6Saved = saved
|
||||
} else {
|
||||
for k, v := range saved {
|
||||
if _, ok := f.v6Saved[k]; !ok {
|
||||
f.v6Saved[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Info("IPv6 forwarding enabled")
|
||||
}
|
||||
f.v6Count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) releaseV6() error {
|
||||
if f.v6Count == 0 {
|
||||
return nil
|
||||
}
|
||||
f.v6Count--
|
||||
if f.v6Count > 0 {
|
||||
func (f *IPForwardingState) RequestForwarding() error {
|
||||
if f.enabledCounter != 0 {
|
||||
f.enabledCounter++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep the saved values on failure so a later release or enable/release
|
||||
// cycle can still restore them; re-restoring an already-restored key is a
|
||||
// no-op since the sysctl already holds the desired value.
|
||||
if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil {
|
||||
return fmt.Errorf("disable IPv6 forwarding: %w", err)
|
||||
if err := systemops.EnableIPForwarding(); err != nil {
|
||||
return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err)
|
||||
}
|
||||
f.v6Saved = nil
|
||||
log.Info("IPv6 forwarding disabled")
|
||||
f.enabledCounter = 1
|
||||
log.Info("IP forwarding enabled")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) ReleaseForwarding() error {
|
||||
if f.enabledCounter == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if f.enabledCounter > 1 {
|
||||
f.enabledCounter--
|
||||
return nil
|
||||
}
|
||||
|
||||
// if failed to disable IP forwarding we anyway decrement the counter
|
||||
f.enabledCounter = 0
|
||||
|
||||
// todo call systemops.DisableIPForwarding()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
//go:build privileged
|
||||
|
||||
package ipfwdstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request
|
||||
// releases a previously held routing-owned v6 reference without touching
|
||||
// references held by DNAT rules.
|
||||
func TestRequestRoutingV6ToV4Transition(t *testing.T) {
|
||||
f := NewIPForwardingState("wt-fwd-test")
|
||||
|
||||
require.NoError(t, f.RequestRouting(true), "request routing with v6")
|
||||
v4, v6 := f.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 reference held")
|
||||
assert.Equal(t, 1, v6, "v6 reference held")
|
||||
|
||||
require.NoError(t, f.RequestRouting(false), "request routing v4-only")
|
||||
v4, v6 = f.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 reference kept")
|
||||
assert.Equal(t, 0, v6, "routing-owned v6 reference released")
|
||||
|
||||
// A DNAT-held reference survives a v4-only routing request.
|
||||
require.NoError(t, f.RequestForwarding(true), "dnat v6 reference")
|
||||
require.NoError(t, f.RequestRouting(false), "repeat v4-only request")
|
||||
_, v6 = f.Counts()
|
||||
assert.Equal(t, 1, v6, "dnat-held v6 reference survives")
|
||||
require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference")
|
||||
|
||||
require.NoError(t, f.ReleaseRouting(), "release routing")
|
||||
v4, v6 = f.Counts()
|
||||
assert.Equal(t, 0, v4, "all v4 references released")
|
||||
assert.Equal(t, 0, v6, "all v6 references released")
|
||||
}
|
||||
@@ -58,7 +58,11 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name))
|
||||
// Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
|
||||
safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
|
||||
safeName = strings.ReplaceAll(safeName, ".", dotEscape)
|
||||
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, safeName)
|
||||
oldVal, err := Set(i, 2, true)
|
||||
if err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
@@ -70,13 +74,6 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
return keys, nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs
|
||||
// like eth0.100) so the name survives the dot-to-slash conversion in Set.
|
||||
func EscapeInterfaceName(name string) string {
|
||||
safe := strings.ReplaceAll(name, "%", percentEscape)
|
||||
return strings.ReplaceAll(safe, ".", dotEscape)
|
||||
}
|
||||
|
||||
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
|
||||
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
|
||||
path := strings.ReplaceAll(key, ".", "/")
|
||||
|
||||
@@ -32,17 +32,8 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -58,17 +58,8 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -763,10 +763,13 @@ func flushRoutes(tableID, family int) error {
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func EnableV4IPForwarding() error {
|
||||
func EnableIPForwarding() error {
|
||||
if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil {
|
||||
log.Warnf("failed to enable IPv6 forwarding: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -43,17 +43,8 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error
|
||||
return r.genericRemoveVPNRoute(prefix, intf)
|
||||
}
|
||||
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//go:build !android
|
||||
|
||||
package systemops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/sysctl"
|
||||
)
|
||||
|
||||
const (
|
||||
// 1 (default) accepts RAs only while forwarding is off; 2 keeps RA
|
||||
// acceptance on regardless, so RA-installed host defaults survive our
|
||||
// v6 forwarding flip.
|
||||
acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra"
|
||||
acceptRADefaultPath = "net.ipv6.conf.default.accept_ra"
|
||||
acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra"
|
||||
)
|
||||
|
||||
// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping
|
||||
// forwarding=1, so RA-installed host defaults survive. Returns the prior values
|
||||
// of sysctls we actually changed; entries already at the target are omitted.
|
||||
func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) {
|
||||
saved := map[string]int{}
|
||||
bumpAcceptRA(saved, wgIfaceName)
|
||||
|
||||
oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false)
|
||||
if err != nil {
|
||||
return saved, err
|
||||
}
|
||||
if oldVal != 1 {
|
||||
saved[ipv6ForwardingPath] = oldVal
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// DisableV6IPForwarding restores what EnableV6IPForwarding captured.
|
||||
func DisableV6IPForwarding(saved map[string]int) error {
|
||||
var result *multierror.Error
|
||||
for key, value := range saved {
|
||||
if _, err := sysctl.Set(key, value, false); err != nil {
|
||||
result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func bumpAcceptRA(saved map[string]int, wgIfaceName string) {
|
||||
// Also bump conf.default so interfaces created while forwarding is on
|
||||
// (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs.
|
||||
bumpAcceptRAKey(saved, acceptRADefaultPath)
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.Warnf("list interfaces for accept_ra: %v", err)
|
||||
return
|
||||
}
|
||||
for _, intf := range interfaces {
|
||||
if intf.Name == "lo" || intf.Name == wgIfaceName {
|
||||
continue
|
||||
}
|
||||
bumpAcceptRAForInterface(saved, intf.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func bumpAcceptRAForInterface(saved map[string]int, name string) {
|
||||
// Build procfs path from name, not the dotted key: VLAN names like eth0.100.
|
||||
if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil {
|
||||
return
|
||||
}
|
||||
bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name)))
|
||||
}
|
||||
|
||||
func bumpAcceptRAKey(saved map[string]int, key string) {
|
||||
// onlyIfOne=true: leave admin overrides (0, 2) alone.
|
||||
oldVal, err := sysctl.Set(key, 2, true)
|
||||
if err != nil {
|
||||
log.Warnf("bump %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
// With onlyIfOne, a write only happened when the old value was 1; values
|
||||
// left untouched (0, 2) must not be recorded for restore.
|
||||
if oldVal == 1 {
|
||||
saved[key] = oldVal
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -28,6 +29,13 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
@@ -200,8 +208,10 @@ func (c *Client) Stop() {
|
||||
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
|
||||
// It works with or without a running engine: when the engine is up it reuses
|
||||
// the live config, sync response and client metrics; otherwise it loads the
|
||||
// config from disk (or the preloaded tvOS config).
|
||||
func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
// config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
|
||||
cfg, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config so we can reach management.
|
||||
@@ -251,6 +261,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2781,6 +2781,11 @@ type DebugBundleRequest struct {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2857,6 +2862,13 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
|
||||
if x != nil {
|
||||
return x.AnonymizeLevel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DebugBundleResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
@@ -7253,7 +7265,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
|
||||
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
|
||||
"\x17ForwardingRulesResponse\x12,\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
|
||||
"\x12DebugBundleRequest\x12\x1c\n" +
|
||||
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
|
||||
"\n" +
|
||||
@@ -7264,7 +7276,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\x12&\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
|
||||
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -540,6 +540,11 @@ message DebugBundleRequest {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
bool uploadInsecure = 7;
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
string anonymizeLevel = 8;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -122,6 +123,7 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
|
||||
IncludeSystemInfo: req.GetSystemInfo(),
|
||||
LogFileCount: req.GetLogFileCount(),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus {
|
||||
|
||||
// ConvertOptions holds parameters for ConvertToStatusOutputOverview.
|
||||
type ConvertOptions struct {
|
||||
Anonymize bool
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts. Only
|
||||
// meaningful when Anonymize is set.
|
||||
AnonymizeLevel anonymize.Level
|
||||
DaemonVersion string
|
||||
DaemonStatus DaemonStatus
|
||||
StatusFilter string
|
||||
@@ -217,6 +220,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
if opts.Anonymize {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(opts.AnonymizeLevel)
|
||||
anonymizeOverview(anonymizer, &overview)
|
||||
}
|
||||
|
||||
@@ -976,6 +980,7 @@ func timeAgo(t time.Time) string {
|
||||
|
||||
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
|
||||
peer.FQDN = a.AnonymizeDomain(peer.FQDN)
|
||||
peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
|
||||
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
|
||||
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
|
||||
}
|
||||
@@ -1007,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
|
||||
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
|
||||
|
||||
overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
|
||||
overview.IP = a.AnonymizeIPString(overview.IP)
|
||||
overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
|
||||
for i, detail := range overview.Relays.Details {
|
||||
|
||||
@@ -71,10 +71,12 @@ type BundleOptions = {
|
||||
hasWindow: boolean;
|
||||
totalSec: number;
|
||||
uploadUrl: string;
|
||||
anonymize: boolean;
|
||||
anonymizeLevel: AnonymizeLevel;
|
||||
systemInfo: boolean;
|
||||
};
|
||||
|
||||
export type AnonymizeLevel = "none" | "default" | "strict";
|
||||
|
||||
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
|
||||
try {
|
||||
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
|
||||
@@ -187,7 +189,10 @@ const runBundleFlow = async (
|
||||
|
||||
if (opts.uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize: opts.anonymize,
|
||||
anonymize: opts.anonymizeLevel !== "none",
|
||||
// The daemon only knows "default" and "strict"; "none" is expressed
|
||||
// through the anonymize flag being off.
|
||||
anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
|
||||
systemInfo: opts.systemInfo,
|
||||
uploadUrl: opts.uploadUrl,
|
||||
logFileCount,
|
||||
@@ -198,7 +203,7 @@ const runBundleFlow = async (
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none");
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(true);
|
||||
@@ -240,7 +245,7 @@ const useDebugBundle = () => {
|
||||
hasWindow: capture && totalSec > 0,
|
||||
totalSec,
|
||||
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
|
||||
anonymize,
|
||||
anonymizeLevel,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
@@ -272,8 +277,8 @@ const useDebugBundle = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
@@ -8,13 +8,22 @@ import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
@@ -24,8 +33,8 @@ export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const durationId = useId();
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
@@ -55,12 +64,71 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={t("settings.troubleshooting.anonymize.label")}
|
||||
helpText={t("settings.troubleshooting.anonymize.help")}
|
||||
/>
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>
|
||||
<span className={"inline-flex items-center gap-1.5"}>
|
||||
{t("settings.troubleshooting.anonymize.label")}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className={"max-w-xs whitespace-normal leading-relaxed"}>
|
||||
{t("settings.troubleshooting.anonymize.info")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={"shrink-0 cursor-default text-nb-gray-400"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("settings.troubleshooting.anonymize.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={anonymizeLevel}
|
||||
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
|
||||
>
|
||||
<DropdownMenuRadioItem value={"none"}>
|
||||
{t("settings.troubleshooting.anonymize.none")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"default"}>
|
||||
{t("settings.troubleshooting.anonymize.default")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"strict"}>
|
||||
{t("settings.troubleshooting.anonymize.strict")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
|
||||
@@ -1013,11 +1013,27 @@
|
||||
},
|
||||
"settings.troubleshooting.anonymize.label": {
|
||||
"message": "Anonymize Sensitive Information",
|
||||
"description": "Toggle label: anonymize sensitive information in the bundle."
|
||||
"description": "Label for the anonymization level dropdown (None, Default, Strict)."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Hides public IP addresses and non-NetBird domains from logs.",
|
||||
"description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)."
|
||||
"message": "Hides IP addresses, domains, and other sensitive values.",
|
||||
"description": "Helper text under the anonymization dropdown. The level details live in the info tooltip."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.",
|
||||
"description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "None",
|
||||
"description": "Dropdown option: no anonymization."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Default",
|
||||
"description": "Dropdown option: default anonymization level."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strict",
|
||||
"description": "Dropdown option: strict anonymization level."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Include System Information",
|
||||
|
||||
@@ -15,10 +15,13 @@ import (
|
||||
)
|
||||
|
||||
type DebugBundleParams struct {
|
||||
Anonymize bool `json:"anonymize"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
Anonymize bool `json:"anonymize"`
|
||||
// AnonymizeLevel is "default" or "strict"; strict also anonymizes
|
||||
// private IP ranges, peer names, and WireGuard public keys.
|
||||
AnonymizeLevel string `json:"anonymizeLevel"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
}
|
||||
|
||||
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
|
||||
@@ -48,11 +51,12 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
return DebugBundleResult{}, err
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: p.Anonymize,
|
||||
AnonymizeLevel: p.AnonymizeLevel,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
|
||||
@@ -438,14 +438,10 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
// Create every provider, all enabled, each with a unique model string so the
|
||||
// proxy's connect-time snapshot carries them all and model→provider routing
|
||||
// is unambiguous (provider toggles after connect don't reconcile to the
|
||||
// proxy, so we enable everything up front). The first create bootstraps the
|
||||
// cluster.
|
||||
// proxy, so we enable everything up front).
|
||||
ids := make([]string, 0, len(matrix))
|
||||
for i, pc := range matrix {
|
||||
for _, pc := range matrix {
|
||||
req := providerRequest(pc)
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
ids = append(ids, prov.Id)
|
||||
|
||||
@@ -82,13 +82,12 @@ func provisionPricedProvider(t *testing.T, ctx context.Context, name string, mod
|
||||
// need NOT be in the catalog — the operator names it and prices it here.
|
||||
dummyKey := "sk-price-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Models: &models,
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &models,
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -113,15 +113,14 @@ func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) {
|
||||
|
||||
// Catch-all provider (no models) so the router forwards any model; a static
|
||||
// bearer key means the router injects a static auth header instead of minting
|
||||
// a GCP token. Bootstraps the cluster if it isn't already.
|
||||
// a GCP token.
|
||||
staticKey := "static-e2e-token"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: tc.name,
|
||||
ProviderId: tc.catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: tc.name,
|
||||
ProviderId: tc.catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
})
|
||||
require.NoError(t, err, "create %s provider", tc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -73,7 +73,6 @@ func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) {
|
||||
{Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
{Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
},
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -61,15 +61,14 @@ func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) {
|
||||
}
|
||||
|
||||
// pRestricted declares the two guardrailed models so routing is deterministic
|
||||
// (model -> provider). Created first, so it carries the bootstrap cluster.
|
||||
// (model -> provider).
|
||||
pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "restricted",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: models(modelSelected, modelOther),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "restricted",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: models(modelSelected, modelOther),
|
||||
})
|
||||
require.NoError(t, err, "create restricted provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) })
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
|
||||
staticKey := "static-e2e-token"
|
||||
enabled := true
|
||||
|
||||
for i, c := range cases {
|
||||
for _, c := range cases {
|
||||
req := api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-pergroup-" + c.name,
|
||||
ProviderId: c.catalogID,
|
||||
@@ -124,9 +124,6 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
|
||||
Enabled: ptr(true),
|
||||
Models: c.models,
|
||||
}
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", c.name)
|
||||
c.providerID = prov.Id
|
||||
@@ -283,13 +280,12 @@ func TestGuardrailMultiGroupUser(t *testing.T) {
|
||||
|
||||
// P1 — union scenario: two restricting policies, one per group.
|
||||
p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-mg-union",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: priced(unionA, unionB, unionC),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "e2e-mg-union",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: priced(unionA, unionB, unionC),
|
||||
})
|
||||
require.NoError(t, err, "create union provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) })
|
||||
|
||||
@@ -115,14 +115,11 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
|
||||
// Providers with their configured (allowed) models; the first bootstraps the cluster.
|
||||
// Providers with their configured (allowed) models
|
||||
ids := make([]string, 0, len(providers))
|
||||
allowed := make([]string, 0, len(providers))
|
||||
for i, pc := range providers {
|
||||
for _, pc := range providers {
|
||||
req := providerRequest(pc)
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
id := prov.Id
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, ready (PAT-authenticated)
|
||||
@@ -42,5 +43,14 @@ func run(m *testing.M) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Bootstrap the account's agent-network endpoint once for the package:
|
||||
// providers no longer have settings side effects, and every data-plane
|
||||
// test expects the shared account pinned to the combined proxy cluster.
|
||||
cluster := harness.AgentNetworkCluster
|
||||
if _, err := srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap agent-network endpoint: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ func ptr[T any](v T) *T { return &v }
|
||||
func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider {
|
||||
t.Helper()
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-dummy-e2e-key"),
|
||||
BootstrapCluster: ptr("eu.proxy.netbird.test"),
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-dummy-e2e-key"),
|
||||
})
|
||||
require.NoError(t, err, "create provider %q", name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
@@ -57,17 +56,11 @@ func TestProviderLifecycle(t *testing.T) {
|
||||
}}
|
||||
}
|
||||
|
||||
for i, pc := range cases {
|
||||
i, pc := i, pc
|
||||
for _, pc := range cases {
|
||||
pc := pc
|
||||
t.Run(pc.name, func(t *testing.T) {
|
||||
req := providerRequest(pc)
|
||||
req.Name = "lc-" + pc.name
|
||||
// Bootstrap the cluster on the first create in case the matrix has
|
||||
// not run (e.g. no provider keys → settings not yet bootstrapped).
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
|
||||
prov, err := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, err, "create %s provider", pc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
@@ -137,45 +130,65 @@ func TestProviderValidation(t *testing.T) {
|
||||
requireClientError(t, err)
|
||||
}
|
||||
|
||||
// TestSettingsRoundTrip flips the collection toggles and confirms cluster /
|
||||
// subdomain stay immutable, then restores the original state.
|
||||
// TestSettingsRoundTrip flips the collection toggles and confirms the
|
||||
// endpoint and proxy address stay immutable, then restores the original
|
||||
// state. A second bootstrap attempt must be rejected as a conflict.
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Settings are bootstrapped on first provider create.
|
||||
newProvider(t, ctx, "Settings Bootstrap")
|
||||
|
||||
// The package's TestMain bootstrapped the shared account's endpoint.
|
||||
before, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings")
|
||||
require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster")
|
||||
require.NotEmpty(t, before.Endpoint, "settings must carry the bootstrapped endpoint")
|
||||
require.NotEmpty(t, before.ProxyAddress, "settings must carry the bootstrapped proxy address")
|
||||
|
||||
require.NotNil(t, before.AccessLogRetentionDays, "bootstrapped settings must carry a retention")
|
||||
beforeRetention := *before.AccessLogRetentionDays
|
||||
|
||||
flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Endpoint: before.Endpoint,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: !before.EnableLogCollection,
|
||||
EnablePromptCollection: !before.EnablePromptCollection,
|
||||
RedactPii: !before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
require.NoError(t, err, "update settings")
|
||||
assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip")
|
||||
assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip")
|
||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||
require.NotNil(t, flipped.AccessLogRetentionDays)
|
||||
assert.Equal(t, beforeRetention, *flipped.AccessLogRetentionDays,
|
||||
"retention sent unchanged must round-trip, not reset to the zero value")
|
||||
assert.Equal(t, before.Endpoint, flipped.Endpoint, "endpoint must be immutable across updates")
|
||||
assert.Equal(t, before.ProxyAddress, flipped.ProxyAddress, "proxy address must be immutable across updates")
|
||||
|
||||
// A cluster different from the pinned one must be rejected; echoing the
|
||||
// pinned one back is valid.
|
||||
// The account is already bootstrapped: a second bootstrap is a conflict,
|
||||
// whatever shape it asks for.
|
||||
_, err = srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("attacker.cluster.invalid"),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// The identity fields ride along on the PUT as a required echo: a request
|
||||
// carrying a different endpoint is rejected without applying anything.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("attacker.cluster.invalid"),
|
||||
Endpoint: "other.cluster.invalid",
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// Restore the original toggles.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(before.Cluster),
|
||||
Endpoint: before.Endpoint,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
require.NoError(t, err, "restore settings")
|
||||
}
|
||||
|
||||
@@ -4,17 +4,20 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/client/rest"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// harnessStartFresh boots a dedicated combined server with its own fresh
|
||||
// account and registers its teardown on t.
|
||||
// account and registers its teardown on t. Unlike the shared srv, the fresh
|
||||
// account has NOT had its agent-network endpoint bootstrapped.
|
||||
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
|
||||
t.Helper()
|
||||
fresh, err := harness.StartCombined(ctx)
|
||||
@@ -28,16 +31,16 @@ func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, er
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
|
||||
// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an
|
||||
// account that has never been bootstrapped: the GET reads as the defaults
|
||||
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
|
||||
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
|
||||
// pins it immutably. The shared srv cannot provide that starting state (any
|
||||
// provider-creating test bootstraps it, and test order is deliberately not
|
||||
// relied on), so this boots a dedicated combined server — the image is
|
||||
// already built and cached by TestMain's StartCombined, so the extra cost is
|
||||
// one container start.
|
||||
func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
// with an empty endpoint/proxy_address, a PUT has no row to update and fails,
|
||||
// and a POST creates the row and assigns the immutable endpoint — labeled
|
||||
// beneath a proxy address here, with the toggle overrides from the same
|
||||
// request applied. The shared srv cannot provide that starting state
|
||||
// (TestMain bootstraps it), so this boots a dedicated combined server — the
|
||||
// image is already built and cached by TestMain's StartCombined, so the extra
|
||||
// cost is one container start.
|
||||
func TestSettingsBootstrapViaPost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
@@ -47,32 +50,35 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
// as an error and not as a null body.
|
||||
before, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings on a fresh account must succeed")
|
||||
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
|
||||
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
|
||||
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
|
||||
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap")
|
||||
assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap")
|
||||
assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape")
|
||||
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
|
||||
// A PUT without a cluster has nothing to pin the account to.
|
||||
// A PUT has no row to update yet — bootstrap is the explicit POST.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: true,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: 30,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// A PUT carrying a cluster bootstraps the account and applies the
|
||||
// mutable fields from the same request. Every toggle is set away from
|
||||
// its bootstrap default so each assertion can actually fail.
|
||||
// A POST with a proxy address bootstraps a labeled endpoint and applies
|
||||
// the toggles from the same request. Every toggle is set away from its
|
||||
// bootstrap default so each assertion can actually fail.
|
||||
const cluster = "e2e.bootstrap.netbird.selfhosted"
|
||||
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(cluster),
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
ProxyAddress: ptr(cluster),
|
||||
EnableLogCollection: ptr(false),
|
||||
EnablePromptCollection: ptr(true),
|
||||
RedactPii: ptr(true),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap settings via PUT must succeed")
|
||||
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
|
||||
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
|
||||
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
|
||||
require.NoError(t, err, "bootstrap settings via POST must succeed")
|
||||
assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request")
|
||||
require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap")
|
||||
assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster),
|
||||
"labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint)
|
||||
assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated")
|
||||
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
|
||||
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
|
||||
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
|
||||
@@ -85,30 +91,176 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
|
||||
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
|
||||
|
||||
// Once bootstrapped, later updates may omit the cluster entirely.
|
||||
// Once bootstrapped, PUT updates the toggles. The identity fields ride
|
||||
// along as a required echo of the assigned values; a matching echo is
|
||||
// accepted and never written.
|
||||
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Endpoint: bootstrapped.Endpoint,
|
||||
ProxyAddress: bootstrapped.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: 21,
|
||||
})
|
||||
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
|
||||
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
|
||||
require.NoError(t, err, "post-bootstrap update must succeed")
|
||||
require.NotNil(t, persisted.AccessLogRetentionDays)
|
||||
assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply")
|
||||
assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched")
|
||||
assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched")
|
||||
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
|
||||
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
|
||||
|
||||
// The cluster is immutable: a different value is rejected rather than
|
||||
// silently ignored, and the rejected update must not disturb anything.
|
||||
// The endpoint is immutable: a PUT carrying a different endpoint is
|
||||
// rejected, and a second bootstrap is rejected as a conflict. Neither
|
||||
// rejected write may disturb anything.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("other.cluster.invalid"),
|
||||
EnableLogCollection: false,
|
||||
Endpoint: "other.cluster.invalid",
|
||||
ProxyAddress: persisted.ProxyAddress,
|
||||
EnableLogCollection: persisted.EnableLogCollection,
|
||||
EnablePromptCollection: persisted.EnablePromptCollection,
|
||||
RedactPii: persisted.RedactPii,
|
||||
AccessLogRetentionDays: 21,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("other.cluster.invalid"),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
final, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
|
||||
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
|
||||
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
|
||||
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
|
||||
require.NoError(t, err, "get settings after the rejected bootstrap must succeed")
|
||||
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint")
|
||||
assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address")
|
||||
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles")
|
||||
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles")
|
||||
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles")
|
||||
}
|
||||
|
||||
// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end:
|
||||
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
|
||||
// equals it, and the pin reads as dedicated — the address-first flow a
|
||||
// self-hosted operator uses before deploying the proxy that will declare it.
|
||||
// The tail covers the recovery path the guarded DELETE exists for: with no
|
||||
// providers and no proxy at the address, the claim can be released and a
|
||||
// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint.
|
||||
func TestSettingsBootstrapSelfAddressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
require.NoError(t, err, "start dedicated combined server")
|
||||
|
||||
created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("gw.e2e.netbird.selfhosted"),
|
||||
})
|
||||
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
||||
assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim")
|
||||
assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
||||
assert.True(t, created.Dedicated, "a self-addressed pin is dedicated")
|
||||
|
||||
// No providers exist and no proxy declares the address, so both delete
|
||||
// guards are clear: the delete releases the claim and the account reads
|
||||
// as unbootstrapped defaults again.
|
||||
require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed")
|
||||
|
||||
after, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after delete must succeed")
|
||||
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped")
|
||||
|
||||
// A second delete has nothing to remove.
|
||||
requireClientError(t, fresh.DeleteSettings(ctx))
|
||||
|
||||
// Re-creating is a fresh bootstrap — the released hostname is free to be
|
||||
// claimed again, or a different one chosen.
|
||||
recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("gw2.e2e.netbird.selfhosted"),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap after delete must succeed")
|
||||
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
|
||||
}
|
||||
|
||||
// TestSettingsConditionalWrites covers the lost-update guard end to end, over
|
||||
// the same REST client the Terraform provider uses: read the settings, take
|
||||
// the entity-tag, and have a write refused when the row moved underneath it.
|
||||
//
|
||||
// The scenario is the one that motivates the feature. A client reads the
|
||||
// settings and computes an update. An operator turns PII redaction on in the
|
||||
// dashboard in the meantime. Without a precondition the client's write puts
|
||||
// redaction straight back off — no error, no drift warning, a
|
||||
// compliance-relevant control silently disabled. With one, the write is
|
||||
// refused and the client can read again.
|
||||
func TestSettingsConditionalWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
require.NoError(t, err, "start dedicated combined server")
|
||||
|
||||
const cluster = "eu.e2e.netbird.selfhosted"
|
||||
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
ProxyAddress: ptr(cluster),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
// What the client plans against.
|
||||
planned, etag, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "read must succeed")
|
||||
require.NotEmpty(t, etag, "the read must carry a validator")
|
||||
assert.Equal(t, bootstrapped.Endpoint, planned.Endpoint)
|
||||
|
||||
_, again, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "second read must succeed")
|
||||
assert.Equal(t, etag, again, "an unchanged row must read as the same validator")
|
||||
|
||||
update := func(redactPii bool, retention int) api.AgentNetworkSettingsRequest {
|
||||
return api.AgentNetworkSettingsRequest{
|
||||
Endpoint: planned.Endpoint,
|
||||
ProxyAddress: planned.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: redactPii,
|
||||
AccessLogRetentionDays: retention,
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's change, which the planning client never saw.
|
||||
_, err = fresh.UpdateSettings(ctx, update(true, 21))
|
||||
require.NoError(t, err, "the intervening update must succeed")
|
||||
|
||||
// The client's write, planned against the earlier read, would have turned
|
||||
// redaction back off. It is refused instead.
|
||||
_, _, err = fresh.UpdateSettingsIfMatch(ctx, update(false, 7), etag)
|
||||
require.Error(t, err, "a stale precondition must be refused")
|
||||
require.True(t, rest.IsPreconditionFailed(err),
|
||||
"the refusal must be a precondition failure, got: %v", err)
|
||||
|
||||
intact, current, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "read after the refusal must succeed")
|
||||
assert.True(t, intact.RedactPii, "the refused write must not have turned redaction off")
|
||||
require.NotNil(t, intact.AccessLogRetentionDays)
|
||||
assert.Equal(t, 21, *intact.AccessLogRetentionDays, "the refused write must not have changed retention")
|
||||
assert.NotEqual(t, etag, current, "the validator must have moved with the intervening update")
|
||||
|
||||
// Retrying against the current validator goes through, and hands back the
|
||||
// validator for the write after it.
|
||||
updated, next, err := fresh.UpdateSettingsIfMatch(ctx, update(true, 7), current)
|
||||
require.NoError(t, err, "a matching precondition must be honoured")
|
||||
require.NotNil(t, updated.AccessLogRetentionDays)
|
||||
assert.Equal(t, 7, *updated.AccessLogRetentionDays, "the conditional write must apply")
|
||||
assert.NotEmpty(t, next, "the write must return a validator")
|
||||
assert.NotEqual(t, current, next, "the write must move the validator")
|
||||
|
||||
// The delete is conditional too, and refusing a stale one leaves the
|
||||
// endpoint claimed.
|
||||
err = fresh.DeleteSettingsIfMatch(ctx, etag)
|
||||
require.Error(t, err, "a stale precondition must refuse the delete")
|
||||
require.True(t, rest.IsPreconditionFailed(err),
|
||||
"the delete must be refused for staleness rather than for a state guard or a server error, got: %v", err)
|
||||
stillThere, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "read after the refused delete must succeed")
|
||||
assert.Equal(t, planned.Endpoint, stillThere.Endpoint, "the refused delete must leave the endpoint claimed")
|
||||
|
||||
require.NoError(t, fresh.DeleteSettingsIfMatch(ctx, next), "a matching precondition must be honoured")
|
||||
gone, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "read after the delete must succeed")
|
||||
assert.Empty(t, gone.Endpoint, "the row must be gone")
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ func TestProviderSkipTLSVerification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// First create bootstraps the account cluster.
|
||||
insecureReq := newReq("skip-tls", insecureModel, true)
|
||||
insecureReq.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
insecureProv, err := srv.CreateProvider(ctx, insecureReq)
|
||||
require.NoError(t, err, "create skip-tls provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) })
|
||||
|
||||
@@ -57,12 +57,11 @@ func TestVLLMProvider(t *testing.T) {
|
||||
// is enumerated so the router dispatches this model string to this provider.
|
||||
dummyKey := "sk-vllm-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "vllm",
|
||||
ProviderId: "vllm",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "vllm",
|
||||
ProviderId: "vllm",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
},
|
||||
|
||||
@@ -126,17 +126,64 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. It exists only
|
||||
// after the first provider create bootstraps it.
|
||||
// CreateSettings bootstraps the account's agent-network settings row,
|
||||
// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled
|
||||
// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated
|
||||
// endpoint) must be set; a second bootstrap returns a conflict.
|
||||
func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. Before the
|
||||
// CreateSettings bootstrap it reads as the defaults with an empty endpoint.
|
||||
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
|
||||
}
|
||||
|
||||
// UpdateSettings applies the mutable collection toggles.
|
||||
// UpdateSettings applies the mutable collection toggles. The request must
|
||||
// echo the assigned endpoint and proxy address unchanged — the server rejects
|
||||
// a PUT that tries to change them.
|
||||
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
|
||||
}
|
||||
|
||||
// DeleteSettings removes the account's settings row, releasing the endpoint.
|
||||
// Refused while providers exist or a proxy is actively serving the endpoint.
|
||||
func (c *Combined) DeleteSettings(ctx context.Context) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/settings")
|
||||
}
|
||||
|
||||
// The conditional-request wrappers go through the typed REST client rather
|
||||
// than anRequest, so the e2e run exercises the client's own header handling —
|
||||
// the quoting on the way out and the unquoting on the way back — against a
|
||||
// real server, which is the path the Terraform provider takes.
|
||||
|
||||
// GetSettingsWithETag reads the settings along with the entity-tag that makes
|
||||
// a following write conditional.
|
||||
func (c *Combined) GetSettingsWithETag(ctx context.Context) (api.AgentNetworkSettings, string, error) {
|
||||
settings, etag, err := c.api.AgentNetwork.GetSettingsWithETag(ctx)
|
||||
if err != nil {
|
||||
return api.AgentNetworkSettings{}, "", err
|
||||
}
|
||||
return *settings, etag, nil
|
||||
}
|
||||
|
||||
// UpdateSettingsIfMatch applies the update only if etag is still current,
|
||||
// returning the entity-tag of the row it wrote.
|
||||
func (c *Combined) UpdateSettingsIfMatch(ctx context.Context, req api.AgentNetworkSettingsRequest, etag string) (api.AgentNetworkSettings, string, error) {
|
||||
settings, newETag, err := c.api.AgentNetwork.UpdateSettingsIfMatch(ctx, req, etag)
|
||||
if err != nil {
|
||||
return api.AgentNetworkSettings{}, "", err
|
||||
}
|
||||
return *settings, newETag, nil
|
||||
}
|
||||
|
||||
// DeleteSettingsIfMatch deletes the settings row only if etag is still
|
||||
// current.
|
||||
func (c *Combined) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
|
||||
return c.api.AgentNetwork.DeleteSettingsIfMatch(ctx, etag)
|
||||
}
|
||||
|
||||
// ListConsumption returns the account's consumption rows (possibly empty).
|
||||
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
|
||||
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)
|
||||
|
||||
5
go.mod
5
go.mod
@@ -99,7 +99,7 @@ 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/quic-go/quic-go v0.55.0
|
||||
github.com/quic-go/quic-go v0.59.1
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.8
|
||||
@@ -239,7 +239,6 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -340,4 +339,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db
|
||||
|
||||
11
go.sum
11
go.sum
@@ -349,8 +349,6 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -490,8 +488,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -582,8 +580,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -793,7 +791,6 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) {
|
||||
|
||||
require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: "eu.proxy.netbird.io",
|
||||
Subdomain: "violet",
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: false,
|
||||
|
||||
@@ -92,6 +92,13 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
}
|
||||
|
||||
func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.doWithHeaders(t, method, path, body, nil)
|
||||
}
|
||||
|
||||
// doWithHeaders is do with request headers, for the cases where the header is
|
||||
// the thing under test (conditional requests).
|
||||
func (f *agentNetworkHandlerFixture) doWithHeaders(t *testing.T, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
@@ -101,6 +108,9 @@ func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
|
||||
@@ -155,12 +155,7 @@ func (h *handler) createProvider(w http.ResponseWriter, r *http.Request) {
|
||||
provider := types.NewProvider(userAuth.AccountId)
|
||||
provider.FromAPIRequest(&req)
|
||||
|
||||
bootstrapCluster := ""
|
||||
if req.BootstrapCluster != nil {
|
||||
bootstrapCluster = *req.BootstrapCluster
|
||||
}
|
||||
|
||||
created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider, bootstrapCluster)
|
||||
created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
|
||||
@@ -12,18 +12,68 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addSettingsEndpoints registers the Agent Network settings routes. The
|
||||
// settings row is bootstrapped server-side on first provider create or on the
|
||||
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
|
||||
// of the mutable collection toggles (cluster/subdomain stay immutable).
|
||||
// addSettingsEndpoints registers the Agent Network settings routes. POST
|
||||
// bootstraps the settings row, assigning the account's immutable endpoint;
|
||||
// GET reads it (defaults with an empty endpoint before bootstrap); PUT
|
||||
// carries every field, replacing the mutable collection toggles and rejecting
|
||||
// any change to the identity fields; DELETE removes the row — guarded so it
|
||||
// stays a bootstrap-repair operation — releasing the endpoint for a fresh
|
||||
// bootstrap.
|
||||
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
// createSettings bootstraps the account's settings row. Exactly one of
|
||||
// proxy_address (labeled endpoint; the server allocates the label) and
|
||||
// endpoint (self-addressed, claimed verbatim) must be provided; optional
|
||||
// collection toggles ride along with defaults for omitted fields.
|
||||
func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkSettingsCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings := types.DefaultSettings(userAuth.AccountId)
|
||||
settings.FromAPICreateRequest(&req)
|
||||
|
||||
proxyAddress := ""
|
||||
if req.ProxyAddress != nil {
|
||||
proxyAddress = *req.ProxyAddress
|
||||
}
|
||||
endpoint := ""
|
||||
if req.Endpoint != nil {
|
||||
endpoint = *req.Endpoint
|
||||
}
|
||||
|
||||
created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
// Emitting the validator here lets a client that just bootstrapped issue a
|
||||
// conditional PUT without an intervening GET.
|
||||
util.SetETag(w, created.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
// updateSettings replaces the mutable settings fields on the account's row.
|
||||
// A request carrying a cluster bootstraps the row when the account doesn't
|
||||
// have one yet.
|
||||
//
|
||||
// An If-Match header makes the update conditional: it is honoured against the
|
||||
// stored row inside the write's transaction, and a stale validator is refused
|
||||
// with 412 rather than overwriting what changed since the client read. Omitting
|
||||
// the header keeps the pre-existing last-write-wins behaviour.
|
||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -40,14 +90,38 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings := &types.Settings{AccountID: userAuth.AccountId}
|
||||
settings.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings)
|
||||
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings, util.IfMatch(r))
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.SetETag(w, updated.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
// deleteSettings removes the account's settings row, releasing the endpoint.
|
||||
// The manager refuses (412) while providers exist or a proxy is actively
|
||||
// serving the endpoint; a later POST bootstraps fresh, allocating a new
|
||||
// endpoint.
|
||||
//
|
||||
// An If-Match header makes the delete conditional, and is worth sending here
|
||||
// even more than on update: both existing guards are about state rather than
|
||||
// staleness, so nothing else stops a client from deleting a row that was
|
||||
// replaced since it read one.
|
||||
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId, util.IfMatch(r)); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// getSettings returns the account's agent-network settings. Accounts that
|
||||
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
||||
// subdomain and endpoint; the manager synthesises that view.
|
||||
@@ -63,5 +137,9 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
// The pre-bootstrap defaults are a representation like any other and carry
|
||||
// a validator too, so an If-Match taken before bootstrap cannot silently
|
||||
// match the row that appeared since.
|
||||
util.SetETag(w, settings.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
|
||||
// convention shared with the account and DNS settings endpoints: settings
|
||||
// always read as a JSON object. Before bootstrap that object carries the
|
||||
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
|
||||
// defaults with an empty endpoint/proxy_address (the "not bootstrapped"
|
||||
// signal) and no timestamps — never a 404 and never the legacy null body.
|
||||
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
@@ -27,9 +32,9 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
|
||||
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
|
||||
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
|
||||
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped")
|
||||
assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped")
|
||||
assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape")
|
||||
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
assert.False(t, got.RedactPii, "defaults must show redaction off")
|
||||
@@ -39,62 +44,149 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
|
||||
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
|
||||
// creates the row (cluster pinned, subdomain assigned) and applies the
|
||||
// mutable fields from the same request.
|
||||
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
|
||||
// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap
|
||||
// shape: a POST carrying a proxy_address allocates a label beneath it, so the
|
||||
// endpoint hangs one label under the shared cluster's address and the pin is
|
||||
// not dedicated. Toggles riding along apply; omitted ones keep defaults.
|
||||
func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
|
||||
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
|
||||
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
|
||||
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request")
|
||||
require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap")
|
||||
assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"),
|
||||
"labeled endpoint must hang off the proxy address: %s", got.Endpoint)
|
||||
label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io")
|
||||
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
||||
assert.False(t, got.Dedicated, "a labeled pin is not dedicated")
|
||||
assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default")
|
||||
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
|
||||
assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
|
||||
assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps")
|
||||
|
||||
// The row is now readable via GET.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
|
||||
var read api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read))
|
||||
assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
|
||||
// without a cluster cannot conjure a settings row out of nothing — there is
|
||||
// no cluster to pin — and surfaces as 404 like the GET.
|
||||
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
|
||||
// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape:
|
||||
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
|
||||
// equals it, and the pin reads as dedicated. The claim is legitimate before
|
||||
// any proxy declares the address (address-first).
|
||||
func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"endpoint": "Brave-Otter.Gateway.Example.com"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint,
|
||||
"endpoint must be claimed verbatim, lowercased")
|
||||
assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint")
|
||||
assert.True(t, got.Dedicated, "a self-addressed pin is dedicated")
|
||||
assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request
|
||||
// contract: proxy_address and endpoint are mutually exclusive and one is
|
||||
// required — both or neither is a validation error, not a guess.
|
||||
func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input
|
||||
// validation: shapes canonicalization cannot repair — trailing dots, embedded
|
||||
// whitespace, empty labels — are rejected with a validation error instead of
|
||||
// landing in an immutable column.
|
||||
func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"trailing dot": `{"endpoint": "gateway.example.com."}`,
|
||||
"leading dot": `{"endpoint": ".gateway.example.com"}`,
|
||||
"inner whitespace": `{"endpoint": "gate way.example.com"}`,
|
||||
"empty label": `{"proxy_address": "eu..proxy.netbird.io"}`,
|
||||
} {
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a
|
||||
// one-time create: a second POST returns 409 and leaves the row untouched.
|
||||
func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String())
|
||||
var first api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first))
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`)
|
||||
assert.Equal(t, http.StatusConflict, rec.Code,
|
||||
"second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap")
|
||||
assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a
|
||||
// settings row out of nothing — bootstrap is the explicit POST — and the
|
||||
// error points the caller there.
|
||||
func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "cluster",
|
||||
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
|
||||
"PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "/api/agent-network/settings",
|
||||
"the error must point the caller at the bootstrap POST: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
|
||||
// with the other PUT endpoints: the request replaces every mutable field, so a
|
||||
// toggle absent from the JSON lands as its zero value rather than being
|
||||
// preserved. Cluster and subdomain survive untouched.
|
||||
// with the other PUT endpoints: the request carries every field, replacing the
|
||||
// mutable ones. The identity fields ride along as a required echo of the
|
||||
// assigned values — compared, never written — so the endpoint and proxy
|
||||
// address survive every accepted update.
|
||||
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`,
|
||||
before.Endpoint, before.ProxyAddress))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
@@ -103,35 +195,400 @@ func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
|
||||
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
|
||||
assert.False(t, got.RedactPii, "sent toggle must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 0, *got.AccessLogRetentionDays,
|
||||
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
|
||||
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
|
||||
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
|
||||
assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply")
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched")
|
||||
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
|
||||
// assigned, a differing cluster is rejected as a validation error instead of
|
||||
// being silently ignored, so callers never observe a value other than the one
|
||||
// they sent. Echoing the assigned cluster back stays valid, which lets
|
||||
// declarative clients send their full desired state idempotently.
|
||||
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
|
||||
// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract:
|
||||
// the PUT carries the identity fields like every other field, but they are an
|
||||
// echo — a request carrying a different endpoint or proxy address is rejected
|
||||
// as a validation error and the row is left untouched. The comparison is
|
||||
// lenient about casing (the stored values are normalized lowercase), so a
|
||||
// client replaying a GET response with different casing is not rejected.
|
||||
func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
|
||||
for name, body := range map[string]string{
|
||||
"changed endpoint": fmt.Sprintf(
|
||||
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
before.ProxyAddress),
|
||||
"changed proxy_address": fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
before.Endpoint),
|
||||
"omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
} {
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
|
||||
// The rejected updates must not have applied anything — toggles included.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint")
|
||||
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address")
|
||||
assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles")
|
||||
|
||||
// An uppercased echo of the assigned values still names the same host and
|
||||
// must be accepted.
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress)))
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the
|
||||
// required-ness of access_log_retention_days does not remove. Marking the field
|
||||
// required changes the generated client type from *int to int, so a generated
|
||||
// client cannot omit it — but nothing validates OpenAPI required-ness at
|
||||
// runtime, so a hand-rolled body without the field still decodes as 0, which
|
||||
// the API documents as "keep indefinitely".
|
||||
//
|
||||
// That is the same latitude the three booleans already have, so it is left
|
||||
// consistent rather than special-cased. This test exists to make the gap
|
||||
// explicit: if request validation is ever added, this expectation is what
|
||||
// changes.
|
||||
func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`,
|
||||
before.Endpoint, before.ProxyAddress))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
|
||||
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 0, *got.AccessLogRetentionDays,
|
||||
"a non-conforming body that omits retention still replaces it with the zero value")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an
|
||||
// account with no settings row is a 404, mirroring the PUT.
|
||||
func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard:
|
||||
// while any provider exists for the account, the delete is refused with 412
|
||||
// and the row survives. Providers route through the endpoint — the guard
|
||||
// keeps DELETE a bootstrap-repair operation rather than a way to abandon a
|
||||
// configured gateway.
|
||||
func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
f.seedProvider(t, "prov-guard")
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete
|
||||
// guard: while a proxy is actively serving the endpoint — an active proxy
|
||||
// row declaring the endpoint hostname as its cluster address, the dedicated
|
||||
// shape — the delete is refused with 412. A proxy that has disconnected no
|
||||
// longer blocks: the guard is about a live serving path, not history.
|
||||
//
|
||||
// The proxy declares its address with mixed casing on purpose: Connect
|
||||
// stores the declared address verbatim while the settings row is normalized
|
||||
// lowercase, and hostnames are case-insensitive, so the guard must match
|
||||
// across the casing difference rather than be sidestepped by it.
|
||||
func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
const endpoint = "gw.dedicated.example.com"
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
now := time.Now()
|
||||
accountID := testAccountID
|
||||
proxyRow := &rpproxy.Proxy{
|
||||
ID: "proxy-guard",
|
||||
SessionID: "sess-1",
|
||||
ClusterAddress: "GW.Dedicated.Example.Com",
|
||||
AccountID: &accountID,
|
||||
LastSeen: now,
|
||||
ConnectedAt: &now,
|
||||
Status: rpproxy.StatusConnected,
|
||||
}
|
||||
require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow))
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
// Once the proxy disconnects it no longer serves the endpoint, so the
|
||||
// delete goes through.
|
||||
require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID))
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the
|
||||
// full-reset semantic that gives replace-on-change clients (e.g. Terraform's
|
||||
// RequiresReplace) a real path: with both guards clear the delete succeeds,
|
||||
// the account reads as the defaults again, and a fresh bootstrap draws a
|
||||
// fresh label. The released hostname is not reserved — a fresh draw may even
|
||||
// legitimately re-pick it — so the assertions check the new row's shape, not
|
||||
// that the label differs.
|
||||
func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults")
|
||||
assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger")
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String())
|
||||
var second api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second))
|
||||
require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint")
|
||||
assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"),
|
||||
"the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint)
|
||||
assert.False(t, second.EnablePromptCollection,
|
||||
"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
|
||||
assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
|
||||
}
|
||||
|
||||
// bootstrapForETag bootstraps a settings row and returns the response body
|
||||
// alongside the validator the bootstrap emitted, which is what a client would
|
||||
// carry into its first conditional write.
|
||||
func bootstrapForETag(t *testing.T, f *agentNetworkHandlerFixture) (api.AgentNetworkSettings, string) {
|
||||
t.Helper()
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var settings api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &settings))
|
||||
|
||||
etag := rec.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag, "bootstrap must emit a validator so a client can PUT without an intervening GET")
|
||||
return settings, etag
|
||||
}
|
||||
|
||||
// putBody renders a complete settings update — every field, with the identity
|
||||
// echo the endpoint requires — so the conditional-request tests differ only in
|
||||
// their headers.
|
||||
func putBody(settings api.AgentNetworkSettings, redactPii bool, retention int) string {
|
||||
return fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": %t, "access_log_retention_days": %d}`,
|
||||
settings.Endpoint, settings.ProxyAddress, redactPii, retention)
|
||||
}
|
||||
|
||||
// TestSettingsHandler_EmitsETag pins that every read and every write hands the
|
||||
// client back a validator, quoted as a strong entity-tag. Without one on the
|
||||
// write responses a client would have to re-GET after every update to stay
|
||||
// able to make the next one conditional.
|
||||
func TestSettingsHandler_EmitsETag(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
// The pre-bootstrap defaults are a representation too, and validate like
|
||||
// one — an If-Match taken here must not match the row that appears later.
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
defaultsETag := rec.Header().Get("ETag")
|
||||
assert.NotEmpty(t, defaultsETag, "the unbootstrapped view must carry a validator")
|
||||
|
||||
settings, bootstrapETag := bootstrapForETag(t, f)
|
||||
assert.Regexp(t, `^"[0-9a-f]+"$`, bootstrapETag, "the validator must be a quoted strong entity-tag")
|
||||
assert.NotEqual(t, defaultsETag, bootstrapETag, "bootstrapping must move the validator")
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, bootstrapETag, rec.Header().Get("ETag"),
|
||||
"reading an unchanged row must derive the same validator the bootstrap returned")
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update must succeed: %s", rec.Body.String())
|
||||
assert.NotEqual(t, bootstrapETag, rec.Header().Get("ETag"),
|
||||
"an update that changed the representation must return a different validator")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutIfMatch walks the conditional-update contract. The
|
||||
// stale case is the one the feature exists for: a client that planned against
|
||||
// an earlier read must be refused rather than silently reverting whatever
|
||||
// changed in between — RedactPii above all, where a silent revert turns a
|
||||
// compliance control off with no error and no drift warning.
|
||||
func TestSettingsHandler_PutIfMatch(t *testing.T) {
|
||||
t.Run("matching validator succeeds", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, etag := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": etag})
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.NotEqual(t, etag, rec.Header().Get("ETag"),
|
||||
"the response must carry the new validator, not the one that was matched")
|
||||
})
|
||||
|
||||
t.Run("stale validator is refused and changes nothing", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
// Someone else writes in between — the dashboard operator enabling
|
||||
// something the planning client never saw.
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
var intervened api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &intervened))
|
||||
|
||||
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"a stale precondition must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
// Asserting the state, not just the status: a partial write would pass
|
||||
// a status-only check.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Equal(t, intervened, after, "the refused update must leave the row byte-identical")
|
||||
})
|
||||
|
||||
t.Run("star matches the existing row", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, _ := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": "*"})
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"* must match any current representation: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("no precondition still succeeds", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, _ := bootstrapForETag(t, f)
|
||||
|
||||
// The back-compatibility guarantee: clients that predate conditional
|
||||
// requests — the dashboard among them — keep last-write-wins.
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"an unconditional update must keep working: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("precondition is checked before the immutability echo", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
|
||||
// A client stale enough to hold an old validator may be stale in its
|
||||
// identity echo too. Answering 412 tells it the useful thing — go and
|
||||
// read again — where 422 would send it hunting an immutability bug.
|
||||
body := fmt.Sprintf(
|
||||
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 7}`,
|
||||
settings.ProxyAddress)
|
||||
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings", body,
|
||||
map[string]string{"If-Match": stale})
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"staleness must be reported ahead of the identity mismatch: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteIfMatch covers the conditional delete, which
|
||||
// carries more weight than the conditional update: both existing delete guards
|
||||
// are about state — no providers, no serving proxy — so nothing else stops a
|
||||
// client from deleting a row that was replaced since it read one.
|
||||
func TestSettingsHandler_DeleteIfMatch(t *testing.T) {
|
||||
t.Run("stale validator is refused and the row survives", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
|
||||
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"a stale precondition must refuse the delete: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Equal(t, settings.Endpoint, after.Endpoint, "the refused delete must leave the row in place")
|
||||
})
|
||||
|
||||
t.Run("matching validator deletes", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
_, etag := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": etag})
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Empty(t, after.Endpoint, "the row must be gone")
|
||||
})
|
||||
|
||||
t.Run("precondition is checked before the state guards", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
f.seedProvider(t, "prov-precondition")
|
||||
|
||||
// Both refusals are 412, so the status cannot tell them apart — the
|
||||
// message must, or a stale client is sent to delete providers it may
|
||||
// not even know about.
|
||||
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code, "the delete must be refused: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "if-match",
|
||||
"staleness must be reported ahead of the provider guard: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package labelgen
|
||||
|
||||
// adjectives is the descriptor half of a generated label. It pairs with the
|
||||
// noun pool in words.go to form `<adjective>-<noun>` labels, and is kept
|
||||
// separate because words.go is almost entirely nouns — drawing both halves
|
||||
// from it produced unreadable pairs like "millet-hammock". Entries are
|
||||
// lowercase ASCII, 4-12 chars, free of hyphens and digits, screened for
|
||||
// offensive/brand/region-specific terms, and disjoint from the noun pool
|
||||
// (enforced by TestAdjectives_AreDisjointFromNouns).
|
||||
var adjectives = []string{
|
||||
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
|
||||
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
|
||||
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
|
||||
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
|
||||
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
|
||||
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
|
||||
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
|
||||
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
|
||||
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
|
||||
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
|
||||
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
|
||||
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
|
||||
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
|
||||
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
|
||||
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
|
||||
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
|
||||
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
|
||||
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
|
||||
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
|
||||
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
|
||||
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
|
||||
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
|
||||
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
|
||||
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
|
||||
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
|
||||
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
|
||||
}
|
||||
@@ -64,3 +64,20 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
}
|
||||
|
||||
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
|
||||
// a single DNS label.
|
||||
//
|
||||
// Unlike PickUnique it takes no `taken` set and has no fallback suffix. The
|
||||
// noun pool holds 857 entries, which is ample per cluster but a hard ceiling
|
||||
// once labels must be unique across one shared zone; pairing an adjective with
|
||||
// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
|
||||
// database constraint and retried by the caller, rather than guessed from a
|
||||
// pre-read set that a concurrent allocation can invalidate.
|
||||
func PickTuple(rng *rand.Rand) string {
|
||||
nouns := uniqueWords()
|
||||
if len(nouns) == 0 || len(adjectives) == 0 {
|
||||
return ""
|
||||
}
|
||||
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
|
||||
}
|
||||
|
||||
@@ -99,3 +99,82 @@ func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
|
||||
}
|
||||
|
||||
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
|
||||
// adjective and a noun, each from its own pool, joined by a single hyphen so
|
||||
// the result stays one DNS label.
|
||||
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
|
||||
nouns := uniqueWords()
|
||||
inNouns := make(map[string]struct{}, len(nouns))
|
||||
for _, w := range nouns {
|
||||
inNouns[w] = struct{}{}
|
||||
}
|
||||
inAdjectives := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
inAdjectives[a] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
for i := 0; i < 200; i++ {
|
||||
got := PickTuple(rng)
|
||||
|
||||
parts := strings.Split(got, "-")
|
||||
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
|
||||
|
||||
_, adjOK := inAdjectives[parts[0]]
|
||||
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
|
||||
_, nounOK := inNouns[parts[1]]
|
||||
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
|
||||
|
||||
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
|
||||
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
|
||||
// are adjectival, and any overlap would let the same word land on both sides.
|
||||
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
|
||||
nouns := make(map[string]struct{}, len(uniqueWords()))
|
||||
for _, w := range uniqueWords() {
|
||||
nouns[w] = struct{}{}
|
||||
}
|
||||
for _, a := range adjectives {
|
||||
_, clash := nouns[a]
|
||||
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
|
||||
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
|
||||
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
_, dup := seen[a]
|
||||
assert.False(t, dup, "Duplicate adjective %q", a)
|
||||
seen[a] = struct{}{}
|
||||
|
||||
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
|
||||
}
|
||||
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
|
||||
}
|
||||
|
||||
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
|
||||
// function of the rng, which is what makes allocation retries reproducible in tests.
|
||||
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
|
||||
a := PickTuple(rand.New(rand.NewSource(42)))
|
||||
b := PickTuple(rand.New(rand.NewSource(42)))
|
||||
assert.Equal(t, a, b, "Same seed must yield the same tuple")
|
||||
}
|
||||
|
||||
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
|
||||
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
|
||||
// yield overwhelmingly distinct values.
|
||||
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
seen := make(map[string]struct{}, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
seen[PickTuple(rng)] = struct{}{}
|
||||
}
|
||||
assert.Greater(t, len(seen), 1900,
|
||||
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
httputil "github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ func ensureSessionKeys(p *types.Provider) error {
|
||||
type Manager interface {
|
||||
GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error)
|
||||
GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error)
|
||||
CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error)
|
||||
CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
|
||||
UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
|
||||
DeleteProvider(ctx context.Context, accountID, userID, providerID string) error
|
||||
|
||||
@@ -71,7 +71,9 @@ type Manager interface {
|
||||
DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error
|
||||
|
||||
GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error)
|
||||
UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error)
|
||||
CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error)
|
||||
UpdateSettings(ctx context.Context, userID string, settings *types.Settings, precondition *httputil.Precondition) (*types.Settings, error)
|
||||
DeleteSettings(ctx context.Context, accountID, userID string, precondition *httputil.Precondition) error
|
||||
|
||||
ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error)
|
||||
ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error)
|
||||
@@ -123,11 +125,10 @@ type managerImpl struct {
|
||||
proxyController proxy.Controller
|
||||
|
||||
// reconcileCache holds the last set of synthesised proxy mappings
|
||||
// per account so reconcile can emit precise Create/Update/Delete
|
||||
// updates instead of a full re-push on every mutation. Keyed by
|
||||
// accountID, then by synthesised service ID.
|
||||
// per account, each paired with the proxy that served it, so a change
|
||||
// of serving proxy can be diffed without re-deriving it.
|
||||
reconcileMu sync.Mutex
|
||||
reconcileCache map[string]map[string]*proto.ProxyMapping
|
||||
reconcileCache map[string]map[string]syntheticMapping
|
||||
|
||||
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
|
||||
// state; concurrent provider creates would otherwise race.
|
||||
@@ -151,7 +152,7 @@ func NewManager(
|
||||
accountManager: accountManager,
|
||||
permissionsManager: permissionsManager,
|
||||
proxyController: proxyController,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
@@ -170,19 +171,14 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
|
||||
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||
}
|
||||
|
||||
// CreateProvider persists a new provider for the account. bootstrapCluster
|
||||
// is used only when the per-account agent-network Settings row hasn't
|
||||
// been created yet; otherwise it is ignored (the cluster is pinned on
|
||||
// Settings and every provider in the account routes through it).
|
||||
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
|
||||
// CreateProvider persists a new provider for the account. Providers have no
|
||||
// settings side effects: the account's endpoint is bootstrapped separately and
|
||||
// explicitly via CreateSettings, and every provider in the account routes
|
||||
// through it.
|
||||
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// An empty api_key would silently produce a synthesised service
|
||||
// that 401s on every upstream request. Surface the misconfiguration
|
||||
@@ -206,16 +202,6 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
||||
return nil, fmt.Errorf("save agent network provider: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
|
||||
// The provider create has already succeeded; logging the
|
||||
// bootstrap miss matches the plan's PoC behaviour. The synth
|
||||
// path treats a missing settings row as a no-op, and the next
|
||||
// provider create retries the bootstrap.
|
||||
log.WithContext(ctx).Debugf("agent-network bootstrap settings for account %s on cluster %s: %v", provider.AccountID, bootstrapCluster, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta())
|
||||
m.reconcile(ctx, provider.AccountID)
|
||||
|
||||
@@ -559,53 +545,70 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// stalePreconditionMsg is the refusal both conditional settings writes return.
|
||||
// Shared so the two cannot drift: DeleteSettings answers 412 for its state
|
||||
// guards as well, so the message is the only thing telling a client that it is
|
||||
// working from an old read rather than tripping over providers or a serving
|
||||
// proxy.
|
||||
const stalePreconditionMsg = "if-match precondition failed: the settings have changed since they were read; GET them again and retry"
|
||||
|
||||
// UpdateSettings replaces the mutable account-level settings — the collection
|
||||
// toggles and retention — on the account's row. When the account has no
|
||||
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
|
||||
// first provider create); without it the update fails with NotFound. On an
|
||||
// existing row the cluster and subdomain are immutable: a differing
|
||||
// settings.Cluster is rejected rather than silently ignored so callers never
|
||||
// observe a value other than what they sent. Because the collection toggles
|
||||
// change the synthesised service config (prompt-capture gating, access-log
|
||||
// emission), a reconcile is triggered so the proxy and peer network maps
|
||||
// converge on the new state.
|
||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
||||
// toggles and retention — on the account's row. The identity fields (Domain,
|
||||
// ProxyAddress) are assigned at bootstrap (CreateSettings) and immutable: the
|
||||
// request carries them, matching the PUT convention of every other endpoint,
|
||||
// but they are only compared against the stored row — a request carrying
|
||||
// different values is rejected, and the stored values are never overwritten.
|
||||
// When the account has no settings row yet the update fails with NotFound.
|
||||
// Because the collection toggles change the synthesised service config
|
||||
// (prompt-capture gating, access-log emission), a reconcile is triggered so
|
||||
// the proxy and peer network maps converge on the new state.
|
||||
//
|
||||
// precondition carries the caller's If-Match, and is nil for an unconditional
|
||||
// update — last write wins, which is what the dashboard wants and what every
|
||||
// client that predates conditional requests gets.
|
||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings, precondition *httputil.Precondition) (*types.Settings, error) {
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestedCluster := strings.TrimSpace(settings.Cluster)
|
||||
|
||||
// The row lock from LockingStrengthUpdate only holds for the duration of
|
||||
// the surrounding transaction, so the read, the cluster-immutability
|
||||
// check, and the save must share one — otherwise concurrent PUTs could
|
||||
// interleave between them.
|
||||
// the surrounding transaction, so the read and the save must share one —
|
||||
// otherwise concurrent PUTs could interleave between them.
|
||||
var updated *types.Settings
|
||||
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
||||
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
if requestedCluster != "" && requestedCluster != existing.Cluster {
|
||||
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
|
||||
}
|
||||
case isNotFound(err):
|
||||
if requestedCluster == "" {
|
||||
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
|
||||
}
|
||||
// Bootstrapping pins the cluster and subdomain — a settings
|
||||
// create on top of the update the caller already passed, matching
|
||||
// the gate on the provider-create bootstrap path.
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them")
|
||||
default:
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
// Evaluated here, under the row lock and inside the write's own
|
||||
// transaction, rather than in the handler: comparing before the
|
||||
// transaction only narrows the race, since two requests can both pass
|
||||
// the check before either writes. Locking the row first makes it a
|
||||
// genuine compare-and-set.
|
||||
//
|
||||
// It comes before the identity comparison because a client holding a
|
||||
// stale validator is stale in its identity echo too, and "you are
|
||||
// working from an old read" is the more accurate answer than "the
|
||||
// endpoint is immutable".
|
||||
if !precondition.Matches(existing.ETag()) {
|
||||
return status.Errorf(status.PreconditionFailed, "%s", stalePreconditionMsg)
|
||||
}
|
||||
|
||||
// The identity echo is compared leniently (trimmed, case-insensitive):
|
||||
// the stored values are normalized lowercase, and a client replaying a
|
||||
// GET response must never be rejected over casing it didn't choose.
|
||||
if !hostnamesEquivalent(settings.Domain, existing.Domain) {
|
||||
return status.Errorf(status.InvalidArgument, "endpoint is immutable: it must match the assigned endpoint %q; delete the settings to release it and bootstrap again", existing.Domain)
|
||||
}
|
||||
if !hostnamesEquivalent(settings.ProxyAddress, existing.ProxyAddress) {
|
||||
return status.Errorf(status.InvalidArgument, "proxy_address is immutable: it must match the assigned proxy address %q; delete the settings to release it and bootstrap again", existing.ProxyAddress)
|
||||
}
|
||||
|
||||
existing.EnableLogCollection = settings.EnableLogCollection
|
||||
existing.EnablePromptCollection = settings.EnablePromptCollection
|
||||
existing.RedactPii = settings.RedactPii
|
||||
@@ -632,6 +635,95 @@ func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, setting
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// hostnamesEquivalent reports whether a caller-supplied hostname names the
|
||||
// same host as a stored (normalized, lowercase) one: equal after trimming and
|
||||
// case folding. No structural validation — an arbitrary mismatch and a
|
||||
// malformed value are both simply "not the assigned value".
|
||||
func hostnamesEquivalent(supplied, stored string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(supplied), stored)
|
||||
}
|
||||
|
||||
// DeleteSettings removes the account's settings row, releasing the endpoint.
|
||||
// Two guards make this a bootstrap-repair operation rather than a way to tear
|
||||
// down a serving gateway, both re-checked under the row lock:
|
||||
//
|
||||
// - No Agent Network providers may exist for the account. Providers route
|
||||
// through the endpoint; delete them first.
|
||||
// - No proxy may be actively serving the endpoint — that is, no active proxy
|
||||
// declares the endpoint hostname as its cluster address. This is the
|
||||
// dedicated (self-addressed) shape's guard: the proxy at the address IS
|
||||
// this account's gateway. A labeled endpoint hangs beneath a shared
|
||||
// cluster's address, and with the account's providers already gone the
|
||||
// shared proxy serves nothing of the account's, so the parent cluster
|
||||
// being up does not block the delete.
|
||||
//
|
||||
// Bootstrapping again after a delete allocates fresh — the released hostname
|
||||
// is not reserved. That full-reset semantic is what gives clients that model
|
||||
// immutability as replace-on-change (e.g. Terraform's RequiresReplace) a real
|
||||
// path: tear down providers, delete, re-create.
|
||||
//
|
||||
// precondition carries the caller's If-Match, and is nil for an unconditional
|
||||
// delete. It matters more here than on update: the two guards above are about
|
||||
// state rather than staleness, so without it nothing stops a client from
|
||||
// deleting a row that was replaced since it last read one.
|
||||
func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string, precondition *httputil.Precondition) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var deleted *types.Settings
|
||||
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
||||
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case isNotFound(err):
|
||||
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; there is nothing to delete")
|
||||
default:
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
// Under the row lock, for the same reason as in UpdateSettings, and
|
||||
// before the state guards: a caller working from an old read should
|
||||
// learn that first, not be told about providers it may not know exist.
|
||||
if !precondition.Matches(existing.ETag()) {
|
||||
return status.Errorf(status.PreconditionFailed, "%s", stalePreconditionMsg)
|
||||
}
|
||||
|
||||
providers, err := tx.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get agent network providers: %w", err)
|
||||
}
|
||||
if len(providers) > 0 {
|
||||
return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while %d provider(s) exist; delete the providers first", len(providers))
|
||||
}
|
||||
|
||||
serving, err := tx.HasActiveProxyAtClusterAddress(ctx, existing.Domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check for a proxy serving the endpoint: %w", err)
|
||||
}
|
||||
if serving {
|
||||
return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while a proxy is actively serving the endpoint %q", existing.Domain)
|
||||
}
|
||||
|
||||
if err := tx.DeleteAgentNetworkSettings(ctx, accountID); err != nil {
|
||||
return fmt.Errorf("delete agent network settings: %w", err)
|
||||
}
|
||||
deleted = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, accountID, accountID, activity.AgentNetworkSettingsDeleted, map[string]any{
|
||||
"endpoint": deleted.Domain,
|
||||
"proxy_address": deleted.ProxyAddress,
|
||||
})
|
||||
m.reconcile(ctx, accountID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNotFound reports whether err is a status.NotFound error.
|
||||
func isNotFound(err error) bool {
|
||||
var sErr *status.Error
|
||||
@@ -678,74 +770,162 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
||||
}
|
||||
}
|
||||
|
||||
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||
// first provider create performs. Pinning the account's cluster and subdomain
|
||||
// is a settings write, so it needs the settings permission on top of the
|
||||
// provider one. No-op once the settings row exists.
|
||||
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
|
||||
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||
}
|
||||
// maxDomainAllocationAttempts bounds the label search when bootstrapping a
|
||||
// labeled endpoint. Package-level (rather than function-local) so tests can
|
||||
// assert on the exhaustion path without duplicating the literal.
|
||||
const maxDomainAllocationAttempts = 10
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||
// settings row when missing. The cluster comes from the create-time
|
||||
// hint the dashboard sends (auto-picked from the active cluster list);
|
||||
// the subdomain is picked from the curated wordlist avoiding
|
||||
// collisions on the same cluster. Idempotent: if a row already exists
|
||||
// it is returned untouched and the hint is ignored. st is the store to
|
||||
// operate on — pass the transaction store when calling from within one.
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||
// CreateSettings bootstraps the per-account settings row, assigning the
|
||||
// account's immutable endpoint. Exactly one of proxyAddress and endpoint must
|
||||
// be non-empty: proxyAddress allocates a labeled endpoint one label beneath
|
||||
// the given cluster address; endpoint claims the given hostname verbatim as a
|
||||
// self-addressed (dedicated) endpoint — a legitimate claim before any proxy
|
||||
// declares the address (address-first). settings carries the account ID and
|
||||
// the initial collection toggles; its identity fields are assigned here.
|
||||
func (m *managerImpl) CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) {
|
||||
if settings == nil || settings.AccountID == "" {
|
||||
return nil, status.Errorf(status.InvalidArgument, "account id is required")
|
||||
}
|
||||
if strings.TrimSpace(providerCluster) == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
hasProxyAddress := strings.TrimSpace(proxyAddress) != ""
|
||||
hasEndpoint := strings.TrimSpace(endpoint) != ""
|
||||
if hasProxyAddress == hasEndpoint {
|
||||
return nil, status.Errorf(status.InvalidArgument, "exactly one of proxy_address and endpoint is required")
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
|
||||
// Fail fast on an existing row for a clean 409; the insert below stays
|
||||
// the authority against concurrent bootstraps (the primary key wins).
|
||||
if _, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); err == nil {
|
||||
return nil, status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
|
||||
} else if !isNotFound(err) {
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
taken := make(map[string]struct{}, len(siblings))
|
||||
for _, s := range siblings {
|
||||
taken[s.Subdomain] = struct{}{}
|
||||
}
|
||||
|
||||
suffix := accountID
|
||||
if len(suffix) > 4 {
|
||||
suffix = suffix[:4]
|
||||
}
|
||||
|
||||
m.labelRngMu.Lock()
|
||||
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
settings := types.DefaultSettings(accountID)
|
||||
settings.Cluster = providerCluster
|
||||
settings.Subdomain = subdomain
|
||||
settings.CreatedAt = now
|
||||
settings.UpdatedAt = now
|
||||
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||
|
||||
var err error
|
||||
if hasEndpoint {
|
||||
err = m.bootstrapSelfAddressed(ctx, settings, endpoint)
|
||||
} else {
|
||||
err = m.bootstrapLabeled(ctx, settings, proxyAddress)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
|
||||
"bootstrapped": true,
|
||||
"endpoint": settings.Domain,
|
||||
"dedicated": settings.Dedicated(),
|
||||
})
|
||||
m.reconcile(ctx, settings.AccountID)
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// bootstrapSelfAddressed claims the given hostname as the account's endpoint,
|
||||
// served only by a proxy declaring exactly that address (Domain ==
|
||||
// ProxyAddress). The domain unique index is the arbiter of availability.
|
||||
func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *types.Settings, endpoint string) error {
|
||||
hostname, err := types.NormalizeHostname(endpoint)
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid endpoint: %s", err)
|
||||
}
|
||||
|
||||
settings.Domain = hostname
|
||||
settings.ProxyAddress = hostname
|
||||
if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
// The violation is either the account primary key (a concurrent
|
||||
// bootstrap for the same account won) or the domain index
|
||||
// (another account holds the hostname). Distinguish by re-read.
|
||||
if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil {
|
||||
return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
|
||||
}
|
||||
return status.Errorf(status.AlreadyExists, "endpoint %s is already taken", hostname)
|
||||
}
|
||||
return fmt.Errorf("create agent network settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootstrapLabeled allocates a labeled endpoint one label beneath the given
|
||||
// cluster address: Domain = <label>.<proxyAddress>, served by whichever proxy
|
||||
// declares the parent. Labels are adjective-noun tuples; a candidate is
|
||||
// checked by read and the domain unique index stays the authority, so a
|
||||
// concurrent allocation of the same tuple surfaces as a unique violation and
|
||||
// another tuple is drawn.
|
||||
func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Settings, proxyAddress string) error {
|
||||
parent, err := types.NormalizeHostname(proxyAddress)
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid proxy_address: %s", err)
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxDomainAllocationAttempts; attempt++ {
|
||||
m.labelRngMu.Lock()
|
||||
label := labelgen.PickTuple(m.labelRng)
|
||||
m.labelRngMu.Unlock()
|
||||
if label == "" {
|
||||
// Only reachable if either word pool were emptied. An empty label
|
||||
// would produce a broken endpoint like ".example.com", so fail
|
||||
// loudly rather than looping or inserting.
|
||||
return fmt.Errorf("allocate agent network endpoint for account %s: label generator returned an empty label", settings.AccountID)
|
||||
}
|
||||
|
||||
candidate, err := types.NormalizeHostname(label + "." + parent)
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "proxy_address leaves no room for a label: %s", err)
|
||||
}
|
||||
|
||||
_, err = m.store.GetAgentNetworkSettingsByDomain(ctx, store.LockingStrengthNone, candidate)
|
||||
if err == nil {
|
||||
log.WithContext(ctx).Tracef("agent-network endpoint %q taken, retrying (attempt %d/%d)", candidate, attempt, maxDomainAllocationAttempts)
|
||||
continue
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return fmt.Errorf("check agent network endpoint availability: %w", err)
|
||||
}
|
||||
|
||||
settings.Domain = candidate
|
||||
settings.ProxyAddress = parent
|
||||
if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
// A concurrent bootstrap for the same account may have won on
|
||||
// the primary key — return the conflict. A lost race on the
|
||||
// domain index just means the tuple was taken between the
|
||||
// read and the insert: draw another.
|
||||
if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil {
|
||||
return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
|
||||
}
|
||||
log.WithContext(ctx).Tracef("agent-network endpoint %q lost an allocation race, retrying (attempt %d/%d)", candidate, attempt, maxDomainAllocationAttempts)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("create agent network settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("allocate agent network endpoint for account %s: %d attempts exhausted", settings.AccountID, maxDomainAllocationAttempts)
|
||||
}
|
||||
|
||||
// isUniqueConstraintError reports whether err is a database unique-constraint
|
||||
// violation, matched on the driver message because CreateAgentNetworkSettings
|
||||
// deliberately returns the driver error unwrapped.
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
||||
strings.Contains(msg, "Error 1062 (23000)") || // mysql
|
||||
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
|
||||
}
|
||||
|
||||
// ListConsumption returns every consumption row recorded for the
|
||||
// account, ordered window-newest-first. Backs the dashboard's basic
|
||||
// counter view; permission gate is the same Read role that gates
|
||||
@@ -879,7 +1059,7 @@ func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provi
|
||||
return &types.Provider{}, nil
|
||||
}
|
||||
|
||||
func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider, _ string) (*types.Provider, error) {
|
||||
func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider) (*types.Provider, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -947,10 +1127,25 @@ func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.
|
||||
return types.DefaultSettings(accountID), nil
|
||||
}
|
||||
|
||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
||||
func (*mockManager) CreateSettings(_ context.Context, _ string, s *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) {
|
||||
if endpoint != "" {
|
||||
s.Domain = endpoint
|
||||
s.ProxyAddress = endpoint
|
||||
} else {
|
||||
s.Domain = "mock." + proxyAddress
|
||||
s.ProxyAddress = proxyAddress
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings, _ *httputil.Precondition) (*types.Settings, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (*mockManager) DeleteSettings(_ context.Context, _, _ string, _ *httputil.Precondition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
||||
// so tests can grant the provider permission while denying (or never
|
||||
// expecting) the settings one.
|
||||
type bootstrapFixture struct {
|
||||
manager Manager
|
||||
store store.Store
|
||||
perms *permissions.MockManager
|
||||
}
|
||||
|
||||
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
perms := permissions.NewMockManager(ctrl)
|
||||
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil),
|
||||
store: st,
|
||||
perms: perms,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
||||
f.perms.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
||||
Return(allowed, context.Background(), nil)
|
||||
}
|
||||
|
||||
func newBootstrapProvider(accountID string) *types.Provider {
|
||||
p := types.NewProvider(accountID)
|
||||
p.Name = "openai"
|
||||
p.UpstreamURL = "https://api.openai.com"
|
||||
p.APIKey = "sk-test"
|
||||
p.Enabled = true
|
||||
return p
|
||||
}
|
||||
|
||||
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
|
||||
// one-time settings bootstrap: creating the first provider with a
|
||||
// bootstrap_cluster pins the account's cluster and subdomain, which is a
|
||||
// settings write and must not ride on the providers permission alone.
|
||||
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("denied without settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.Error(t, err, "bootstrap without settings permission must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
||||
|
||||
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
||||
})
|
||||
|
||||
t.Run("allowed with settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.NoError(t, err, "bootstrap with both permissions must succeed")
|
||||
require.NotNil(t, created)
|
||||
|
||||
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err, "bootstrap must create the settings row")
|
||||
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
|
||||
})
|
||||
|
||||
t.Run("existing settings need no settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: "account1",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: "existing",
|
||||
}), "pre-existing settings row setup must succeed")
|
||||
|
||||
// Only the providers permission may be consulted: gomock fails the
|
||||
// test on any unexpected settings-permission call.
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.NoError(t, err, "create with existing settings must not require the settings permission")
|
||||
})
|
||||
|
||||
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
|
||||
require.NoError(t, err, "create without bootstrap must not require the settings permission")
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,17 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// syntheticMapping pairs a synthesised proxy mapping with the address of the
|
||||
// proxy that serves it. The cluster is recorded rather than derived from the
|
||||
// mapping's domain: ProxyMapping does not carry it, and the previous derivation
|
||||
// -- everything after the first DNS label -- is wrong whenever the service's
|
||||
// domain is not one label under its proxy's address, which silently addressed
|
||||
// updates to a cluster no proxy declares.
|
||||
type syntheticMapping struct {
|
||||
mapping *proto.ProxyMapping
|
||||
cluster string
|
||||
}
|
||||
|
||||
// reconcile recomputes the synthesised reverse-proxy services for an
|
||||
// account, diffs them against the previously-synthesised set in the
|
||||
// in-memory cache, and emits Create / Update / Delete proxy mappings
|
||||
@@ -45,18 +56,21 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
|
||||
}
|
||||
|
||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||
current := make(map[string]*proto.ProxyMapping, len(services))
|
||||
current := make(map[string]syntheticMapping, len(services))
|
||||
for _, svc := range services {
|
||||
if svc == nil || svc.ID == "" {
|
||||
continue
|
||||
}
|
||||
current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg)
|
||||
current[svc.ID] = syntheticMapping{
|
||||
mapping: svc.ToProtoMapping(rpservice.Update, "", oidcCfg),
|
||||
cluster: svc.ProxyCluster,
|
||||
}
|
||||
}
|
||||
|
||||
m.reconcileMu.Lock()
|
||||
previous := m.reconcileCache[accountID]
|
||||
if previous == nil {
|
||||
previous = make(map[string]*proto.ProxyMapping)
|
||||
previous = make(map[string]syntheticMapping)
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
@@ -67,34 +81,36 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
|
||||
}
|
||||
m.reconcileMu.Unlock()
|
||||
|
||||
for _, mapping := range creates {
|
||||
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
|
||||
for _, entry := range creates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
for _, mapping := range updates {
|
||||
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
|
||||
for _, entry := range updates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
for _, mapping := range deletes {
|
||||
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
|
||||
for _, entry := range deletes {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// diffMappings classifies the previous→current transition for a
|
||||
// single account into Create / Update / Delete sets.
|
||||
// diffMappings classifies the previous→current transition for a single
|
||||
// account into Create / Update / Delete sets.
|
||||
//
|
||||
// Cluster moves (current.cluster != previous.cluster) are surfaced as
|
||||
// a Delete on the old cluster + Create on the new — handled by
|
||||
// emitting both a delete (on previous mapping) and a create (on the
|
||||
// current mapping) for that service ID.
|
||||
func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, updates, deletes []*proto.ProxyMapping) {
|
||||
// A change of serving proxy for the same service ID is surfaced as a Delete
|
||||
// addressed to the old proxy plus a Create addressed to the new one, so the
|
||||
// mapping actually moves. Comparing the recorded cluster is what makes that
|
||||
// detectable: with a placement-free endpoint the mapping's domain is identical
|
||||
// before and after the move, so nothing about the mapping itself reveals it.
|
||||
func diffMappings(previous, current map[string]syntheticMapping) (creates, updates, deletes []syntheticMapping) {
|
||||
for id, cur := range current {
|
||||
prev, existed := previous[id]
|
||||
switch {
|
||||
case !existed:
|
||||
creates = append(creates, cur)
|
||||
case prev.GetDomain() == "" || cur.GetAccountId() == prev.GetAccountId() && currentClusterChanged(prev, cur):
|
||||
case prev.mapping.GetDomain() == "" ||
|
||||
cur.mapping.GetAccountId() == prev.mapping.GetAccountId() && prev.cluster != cur.cluster:
|
||||
deletes = append(deletes, prev)
|
||||
creates = append(creates, cur)
|
||||
default:
|
||||
@@ -108,24 +124,3 @@ func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, up
|
||||
}
|
||||
return creates, updates, deletes
|
||||
}
|
||||
|
||||
func currentClusterChanged(prev, cur *proto.ProxyMapping) bool {
|
||||
return clusterFromMapping(prev) != clusterFromMapping(cur)
|
||||
}
|
||||
|
||||
// clusterFromMapping returns the cluster the mapping should be sent
|
||||
// to. ProxyMapping doesn't carry the cluster directly, so we rely on
|
||||
// the synthesised service's domain (`<slug>.<cluster>`) and split on
|
||||
// the first '.'.
|
||||
func clusterFromMapping(m *proto.ProxyMapping) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
domain := m.GetDomain()
|
||||
for i := 0; i < len(domain); i++ {
|
||||
if domain[i] == '.' {
|
||||
return domain[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *stor
|
||||
return &managerImpl{
|
||||
store: mockStore,
|
||||
proxyController: mockProxy,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}, mockStore, mockProxy
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ func newReconcileTestPolicy(providerID, sourceGroupID string) *types.Policy {
|
||||
|
||||
func newReconcileTestSettings() *types.Settings {
|
||||
return &types.Settings{
|
||||
AccountID: "acct-1",
|
||||
Cluster: "eu.proxy.netbird.io",
|
||||
Subdomain: "violet",
|
||||
AccountID: "acct-1",
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestReconcile_PolicyRemoved_EmitsDelete(t *testing.T) {
|
||||
func TestReconcile_NilProxyController_NoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := &managerImpl{
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}
|
||||
// Must not panic; must not query the store.
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
@@ -212,21 +212,78 @@ func TestReconcile_EmptyAccountID_NoOp(t *testing.T) {
|
||||
mgr.reconcile(ctx, "")
|
||||
}
|
||||
|
||||
func TestClusterFromMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
domain string
|
||||
want string
|
||||
}{
|
||||
{"simple", "openai.eu.proxy.netbird.io", "eu.proxy.netbird.io"},
|
||||
{"deeply nested", "a.b.c.d", "b.c.d"},
|
||||
{"no dot", "openai", ""},
|
||||
{"empty", "", ""},
|
||||
// TestDiffMappings_ServingProxyChange — when the proxy serving an account
|
||||
// changes, the same service ID must be deleted on the old proxy and created on
|
||||
// the new one. The cluster cannot be recovered from the mapping's domain: with a
|
||||
// placement-free endpoint the domain does not change at all when the serving
|
||||
// proxy does, so a domain-derived cluster sees no change and emits a plain
|
||||
// update, addressed to a proxy that does not exist.
|
||||
func TestDiffMappings_ServingProxyChange(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clusterFromMapping(&proto.ProxyMapping{Domain: tt.domain})
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
current := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "brave-otter.gateway.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
|
||||
if assert.Len(t, deletes, 1, "the old proxy must be told to drop the mapping") {
|
||||
assert.Equal(t, "proxy.example.com", deletes[0].cluster)
|
||||
}
|
||||
if assert.Len(t, creates, 1, "the new proxy must be told to add it") {
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", creates[0].cluster)
|
||||
}
|
||||
assert.Empty(t, updates, "a serving-proxy move is a delete plus a create, not an update")
|
||||
}
|
||||
|
||||
// TestDiffMappings_UnchangedClusterIsAnUpdate keeps the ordinary path: same
|
||||
// service, same proxy, changed contents.
|
||||
func TestDiffMappings_UnchangedClusterIsAnUpdate(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
current := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
|
||||
assert.Empty(t, creates)
|
||||
assert.Empty(t, deletes)
|
||||
if assert.Len(t, updates, 1) {
|
||||
assert.Equal(t, "proxy.example.com", updates[0].cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster — a service that has
|
||||
// gone away is deleted on the cluster it was last served by, which is recorded
|
||||
// rather than re-derived.
|
||||
func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "brave-otter.gateway.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, map[string]syntheticMapping{})
|
||||
|
||||
assert.Empty(t, creates)
|
||||
assert.Empty(t, updates)
|
||||
if assert.Len(t, deletes, 1) {
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
||||
// so tests can grant or deny the settings permission per case.
|
||||
type bootstrapFixture struct {
|
||||
manager Manager
|
||||
store store.Store
|
||||
perms *permissions.MockManager
|
||||
}
|
||||
|
||||
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
perms := permissions.NewMockManager(ctrl)
|
||||
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil),
|
||||
store: st,
|
||||
perms: perms,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
||||
f.perms.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
||||
Return(allowed, context.Background(), nil)
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID, proxyAddress, endpoint string) (*types.Settings, error) {
|
||||
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
|
||||
}
|
||||
|
||||
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
|
||||
// account's immutable endpoint, a settings write requiring the settings
|
||||
// Create permission — and a denial leaves no row behind.
|
||||
func TestCreateSettingsRequiresPermission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
||||
require.Error(t, err, "bootstrap without the settings permission must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
||||
}
|
||||
|
||||
// TestCreateSettingsLabeled pins the labeled shape: the server allocates an
|
||||
// adjective-noun label beneath the proxy address, the pin is not dedicated,
|
||||
// and the domain records the full endpoint hostname.
|
||||
func TestCreateSettingsLabeled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
|
||||
require.NoError(t, err, "labeled bootstrap must succeed")
|
||||
assert.Equal(t, "cluster1.example.com", created.ProxyAddress, "proxy address must be pinned lowercased")
|
||||
require.True(t, strings.HasSuffix(created.Domain, ".cluster1.example.com"),
|
||||
"domain must hang one label beneath the proxy address: %s", created.Domain)
|
||||
label := strings.TrimSuffix(created.Domain, ".cluster1.example.com")
|
||||
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
||||
assert.False(t, created.Dedicated(), "a labeled pin is not dedicated")
|
||||
assert.Equal(t, created.Domain, created.Endpoint(), "the endpoint is the domain column")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err, "bootstrap must persist the row")
|
||||
assert.Equal(t, created.Domain, stored.Domain)
|
||||
assert.Equal(t, created.ProxyAddress, stored.ProxyAddress)
|
||||
}
|
||||
|
||||
// TestCreateSettingsSelfAddressed pins the dedicated shape: the endpoint is
|
||||
// claimed verbatim (normalized), Domain == ProxyAddress, and the claim
|
||||
// succeeds with no proxy declaring the address yet (address-first).
|
||||
func TestCreateSettingsSelfAddressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "", "Brave-Otter.GW.Example.com")
|
||||
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
||||
assert.Equal(t, "brave-otter.gw.example.com", created.Domain, "endpoint must be claimed lowercased")
|
||||
assert.Equal(t, created.Domain, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
||||
assert.True(t, created.Dedicated(), "a self-addressed pin is dedicated")
|
||||
}
|
||||
|
||||
// TestCreateSettingsIdentityFieldValidation pins the request contract: exactly
|
||||
// one of proxyAddress and endpoint, and both must be well-formed hostnames.
|
||||
func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cases := map[string]struct {
|
||||
proxyAddress string
|
||||
endpoint string
|
||||
}{
|
||||
"neither": {"", ""},
|
||||
"both": {"cluster1.example.com", "gw.example.com"},
|
||||
"trailing dot endpoint": {"", "gw.example.com."},
|
||||
"leading dot endpoint": {"", ".gw.example.com"},
|
||||
"whitespace inside": {"", "g w.example.com"},
|
||||
"empty label in parent": {"eu..example.com", ""},
|
||||
"hyphen-edged label": {"", "-gw.example.com"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", tc.proxyAddress, tc.endpoint)
|
||||
require.Error(t, err, "invalid identity input must be rejected")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSettingsConflictsOnSecondBootstrap pins that bootstrap is a
|
||||
// one-time create per account: a second call is a conflict, whatever shape it
|
||||
// asks for, and the original row survives untouched.
|
||||
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account1", "user1", "", "other.example.com")
|
||||
require.Error(t, err, "second bootstrap must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "second bootstrap must surface as a conflict")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.Domain, stored.Domain, "the original endpoint must survive the rejected bootstrap")
|
||||
}
|
||||
|
||||
// TestCreateSettingsEndpointTaken pins global hostname uniqueness: a hostname
|
||||
// held by one account cannot be claimed by another, in either direction —
|
||||
// self-addressed onto self-addressed, or self-addressed onto an allocated
|
||||
// labeled endpoint.
|
||||
func TestCreateSettingsEndpointTaken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
first, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
|
||||
require.Error(t, err, "a taken hostname must be refused")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "the refusal must surface as a conflict")
|
||||
|
||||
f.expectPermission("account3", "user3", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account3", "user3", "", first.Domain)
|
||||
require.Error(t, err, "claiming another account's endpoint must be refused")
|
||||
}
|
||||
|
||||
// TestCreateProviderHasNoSettingsSideEffects pins the decoupling: provider
|
||||
// create needs only the providers permission (gomock fails the test on any
|
||||
// settings-permission call) and never creates a settings row.
|
||||
func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
provider := types.NewProvider("account1")
|
||||
provider.Name = "openai"
|
||||
provider.UpstreamURL = "https://api.openai.com"
|
||||
provider.APIKey = "sk-test"
|
||||
provider.Enabled = true
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
||||
require.NoError(t, err, "provider create must succeed on the providers permission alone")
|
||||
require.NotNil(t, created)
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "provider create must not conjure a settings row")
|
||||
}
|
||||
199
management/internals/modules/agentnetwork/settings_etag_test.go
Normal file
199
management/internals/modules/agentnetwork/settings_etag_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
httputil "github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// ifMatch builds the precondition a client sending this validator would
|
||||
// produce, by going through the same header parse the handler uses rather than
|
||||
// reaching past it.
|
||||
func ifMatch(t *testing.T, etag string) *httputil.Precondition {
|
||||
t.Helper()
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", strconv.Quote(etag))
|
||||
return httputil.IfMatch(r)
|
||||
}
|
||||
|
||||
// updateFor renders a complete update for the given row, echoing the identity
|
||||
// fields the endpoint requires and setting retention to tell writers apart.
|
||||
func updateFor(settings *types.Settings, retention int) *types.Settings {
|
||||
return &types.Settings{
|
||||
AccountID: settings.AccountID,
|
||||
Domain: settings.Domain,
|
||||
ProxyAddress: settings.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: retention,
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSettingsPreconditionSerializesConcurrentWriters is the test the
|
||||
// design rests on. Two writers start from the same validator and race; exactly
|
||||
// one may win.
|
||||
//
|
||||
// An implementation that compares the validator before opening the write
|
||||
// transaction passes every sequential test in this suite and fails here: both
|
||||
// writers read the same row, both find their precondition satisfied, and both
|
||||
// then write — which is the lost update the feature exists to prevent, merely
|
||||
// narrowed to a smaller window. Holding the row under LockingStrengthUpdate
|
||||
// and comparing inside the write's own transaction is what makes it a genuine
|
||||
// compare-and-set.
|
||||
//
|
||||
// The test store is sqlite, which serializes writers of its own accord, so
|
||||
// what this pins directly is the outcome — exactly one success — rather than
|
||||
// the mechanism. It still has teeth against the check-before-transaction
|
||||
// shape, whose two reads interleave freely before either write. Running it
|
||||
// against postgres (NB_STORE_ENGINE_POSTGRES_DSN) exercises real concurrent
|
||||
// transactions.
|
||||
func TestUpdateSettingsPreconditionSerializesConcurrentWriters(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
// Both writers plan against this one read, as a client that read, computed
|
||||
// a diff and is about to write the whole object back would.
|
||||
shared := created.ETag()
|
||||
|
||||
// noWrite is a retention value neither writer sends and the API would
|
||||
// never store, so an assertion that lands on it is a test bug rather than
|
||||
// a silently satisfied comparison. Zero would not do: the API documents 0
|
||||
// as "keep indefinitely", so it is a value the row could legitimately hold.
|
||||
const noWrite = -1
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
start = make(chan struct{})
|
||||
errs = make([]error, 2)
|
||||
wrote = []int{7, 21}
|
||||
returned = []int{noWrite, noWrite}
|
||||
)
|
||||
for i := range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, wrote[i]), ifMatch(t, shared))
|
||||
errs[i] = err
|
||||
if err == nil {
|
||||
returned[i] = updated.AccessLogRetentionDays
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
succeeded, winner := 0, noWrite
|
||||
for i, err := range errs {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
winner = wrote[i]
|
||||
assert.Equal(t, wrote[i], returned[i], "the winner's response must carry what it sent")
|
||||
continue
|
||||
}
|
||||
assert.Truef(t, isPreconditionFailed(err),
|
||||
"the losing writer must be refused for staleness, got: %v (writer %d)", err, i)
|
||||
}
|
||||
require.Equal(t, 1, succeeded, "exactly one writer may win: %v", errs)
|
||||
|
||||
// The row must carry the winner's value and nothing blended.
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err, "the row must survive the race")
|
||||
assert.Equal(t, winner, stored.AccessLogRetentionDays,
|
||||
"the stored row must be exactly what the winning writer sent")
|
||||
assert.NotEqual(t, shared, stored.ETag(), "the surviving row must derive a new validator")
|
||||
}
|
||||
|
||||
// TestUpdateSettingsUnconditionalIgnoresStaleness pins the back-compatibility
|
||||
// half: without a precondition the manager keeps last-write-wins, which is
|
||||
// what the dashboard relies on and what any client that predates conditional
|
||||
// requests does.
|
||||
func TestUpdateSettingsUnconditionalIgnoresStaleness(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
_, err = f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
|
||||
require.NoError(t, err, "the first unconditional update must succeed")
|
||||
|
||||
// The second writer is working from a read that is now stale, and with no
|
||||
// precondition it overwrites regardless.
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 7), nil)
|
||||
require.NoError(t, err, "an unconditional update must not be refused for staleness")
|
||||
assert.Equal(t, 7, updated.AccessLogRetentionDays, "last write wins without a precondition")
|
||||
}
|
||||
|
||||
// TestDeleteSettingsPreconditionRefusesStale pins the conditional delete at
|
||||
// the manager level: a stale validator refuses, and the row is still there
|
||||
// afterwards. Deletion is the destructive operation and its two other guards
|
||||
// are about state rather than staleness, so this is the only thing standing
|
||||
// between a client working from an old read and a released endpoint.
|
||||
func TestDeleteSettingsPreconditionRefusesStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
stale := created.ETag()
|
||||
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
|
||||
require.NoError(t, err, "the intervening update must succeed")
|
||||
|
||||
err = f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, stale))
|
||||
require.Error(t, err, "a stale precondition must refuse the delete")
|
||||
assert.True(t, isPreconditionFailed(err), "the refusal must be a precondition failure, got: %v", err)
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err, "the refused delete must leave the row in place")
|
||||
assert.Equal(t, created.Domain, stored.Domain, "the endpoint must not have been released")
|
||||
|
||||
// The validator the intervening update returned is the current one, and
|
||||
// deleting with it goes through.
|
||||
require.NoError(t, f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, updated.ETag())),
|
||||
"a matching precondition must be honoured")
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
assert.Error(t, err, "the row must be gone")
|
||||
}
|
||||
|
||||
// isPreconditionFailed reports whether err is the 412-mapped status error.
|
||||
func isPreconditionFailed(err error) bool {
|
||||
var sErr *status.Error
|
||||
return errors.As(err, &sErr) && sErr.Type() == status.PreconditionFailed
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, clusterAddr)
|
||||
settingsRows, err := s.GetAgentNetworkSettingsByProxyAddress(ctx, store.LockingStrengthNone, clusterAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
@@ -116,53 +116,41 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
|
||||
}
|
||||
|
||||
// SynthesizeServiceForDomain resolves a single agent-network service by its
|
||||
// public endpoint domain. It lists the (few) settings rows on the domain's
|
||||
// cluster, matches the one whose endpoint equals the domain, and synthesises
|
||||
// only that account — avoiding full per-account synthesis for every tenant on
|
||||
// the cluster, which is what auth/session paths previously paid. Returns nil
|
||||
// (no error) when no account owns the domain.
|
||||
// public endpoint domain — a point query on the settings domain unique index,
|
||||
// then synthesis of just that account. Returns nil (no error) when no account
|
||||
// owns the domain.
|
||||
func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) {
|
||||
domain = strings.TrimSpace(domain)
|
||||
cluster := clusterFromDomain(domain)
|
||||
if domain != "" && cluster != "" {
|
||||
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
|
||||
}
|
||||
|
||||
settings, err := s.GetAgentNetworkSettingsByDomain(ctx, store.LockingStrengthNone, domain)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
|
||||
}
|
||||
for _, settings := range settingsRows {
|
||||
if settings == nil || settings.Endpoint() != domain {
|
||||
continue
|
||||
}
|
||||
services, serr := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
break
|
||||
return nil, fmt.Errorf("get agent network settings by domain: %w", err)
|
||||
}
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
|
||||
}
|
||||
|
||||
// clusterFromDomain returns the cluster portion of an endpoint domain (every
|
||||
// label after the first).
|
||||
func clusterFromDomain(domain string) string {
|
||||
if i := strings.IndexByte(domain, '.'); i >= 0 {
|
||||
return domain[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SynthesizeServices builds the in-memory reverse-proxy service that
|
||||
// fronts the account's agent-network gateway. Returns nil when the
|
||||
// account has no settings row, no enabled providers, or no enabled
|
||||
// policies — in any of those cases there's nothing useful to expose.
|
||||
//
|
||||
// One service per (account, settings.Cluster) is emitted. The router
|
||||
// One service per (account, settings.ProxyAddress) is emitted. The router
|
||||
// middleware encodes a denormalised model→provider routing table
|
||||
// (auth headers + decrypted API keys baked in); the policy_check
|
||||
// middleware encodes per-provider authorised group IDs derived from
|
||||
@@ -175,7 +163,7 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || strings.TrimSpace(settings.Cluster) == "" {
|
||||
if !ok || strings.TrimSpace(settings.ProxyAddress) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -934,7 +922,7 @@ func buildAccountService(
|
||||
middlewares []rpservice.MiddlewareConfig,
|
||||
sessionPriv, sessionPub string,
|
||||
) *rpservice.Service {
|
||||
cluster := settings.Cluster
|
||||
cluster := settings.ProxyAddress
|
||||
domain := settings.Endpoint()
|
||||
serviceID := SynthesizedServiceIDPrefix + accountID
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestReconcile_RealStore_PushesPrivateAfterStatusToggle(t *testing.T) {
|
||||
store: s,
|
||||
accountManager: noopAccountManager{},
|
||||
proxyController: ctrl,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}
|
||||
|
||||
m.reconcile(ctx, testAccountID) // initial, provider enabled
|
||||
|
||||
@@ -19,15 +19,14 @@ import (
|
||||
const (
|
||||
testAccountID = "acct-1"
|
||||
testCluster = "eu.proxy.netbird.io"
|
||||
testSubdomain = "violet"
|
||||
testEndpoint = "violet.eu.proxy.netbird.io"
|
||||
)
|
||||
|
||||
func newSynthTestSettings() *types.Settings {
|
||||
return &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Subdomain: testSubdomain,
|
||||
AccountID: testAccountID,
|
||||
Domain: testEndpoint,
|
||||
ProxyAddress: testCluster,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,13 +15,23 @@ import (
|
||||
// the long-term aggregate and are retained independently.
|
||||
const DefaultAccessLogRetentionDays = 30
|
||||
|
||||
// Settings is the per-account agent-network configuration row. One
|
||||
// row per account. Cluster + Subdomain are immutable once written and
|
||||
// produce the public endpoint agents call (`<subdomain>.<cluster>`).
|
||||
// Settings is the per-account agent-network configuration row. One row per
|
||||
// account. Domain and ProxyAddress are assigned at bootstrap and immutable
|
||||
// thereafter; a persisted row is always fully allocated — there is no "row
|
||||
// exists, endpoint pending" state.
|
||||
type Settings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
|
||||
|
||||
// Domain is the gateway endpoint hostname agents call. Globally unique
|
||||
// across accounts. Sized explicitly because MySQL cannot index an
|
||||
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
|
||||
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
|
||||
|
||||
// ProxyAddress is the declared cluster address of the proxy serving this
|
||||
// account's gateway. Either equal to Domain — a proxy dedicated to this
|
||||
// account, declaring the tenant's own hostname — or Domain's immediate
|
||||
// parent, with the endpoint one label beneath it on a shared cluster.
|
||||
ProxyAddress string `gorm:"type:varchar(255);index:idx_agent_network_settings_proxy_address"`
|
||||
|
||||
// Account-level collection controls sourced by the synthesizer.
|
||||
// EnableLogCollection gates the per-request access-log trail and defaults
|
||||
@@ -45,9 +58,9 @@ func (Settings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// DefaultSettings returns the settings an account observes before its row is
|
||||
// bootstrapped: log collection on with the default retention, everything else
|
||||
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
|
||||
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
|
||||
// and the freshly bootstrapped row agree.
|
||||
// off, and no domain or proxy address assigned yet. Bootstrap persists exactly
|
||||
// these values plus the assigned domain and proxy address, so the
|
||||
// pre-bootstrap read and the freshly bootstrapped row agree.
|
||||
func DefaultSettings(accountID string) *Settings {
|
||||
return &Settings{
|
||||
AccountID: accountID,
|
||||
@@ -56,24 +69,83 @@ func DefaultSettings(accountID string) *Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at:
|
||||
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
|
||||
func (s *Settings) Endpoint() string {
|
||||
if s.Cluster == "" || s.Subdomain == "" {
|
||||
return ""
|
||||
}
|
||||
return s.Subdomain + "." + s.Cluster
|
||||
// etagLength is how much of the hash the validator carries. 16 hex characters
|
||||
// — 64 bits — is far more than enough to make an accidental collision between
|
||||
// two representations of one account's settings unreachable, and keeps the
|
||||
// header short enough to read in a log line.
|
||||
const etagLength = 16
|
||||
|
||||
// ETag returns a strong validator over the settings representation, for
|
||||
// conditional requests (RFC 9110 If-Match). The value is unquoted; applying
|
||||
// the quoting is the transport layer's job.
|
||||
//
|
||||
// The hash covers an explicit field tuple rather than the marshalled API
|
||||
// representation: field ordering in the generated API types is not a contract,
|
||||
// so hashing serialized output would make the validator churn with codegen.
|
||||
// Two exclusions are deliberate:
|
||||
//
|
||||
// - AccountID identifies the resource — it is the URL, not the
|
||||
// representation. Including it would make the validator differ between
|
||||
// accounts whose settings are genuinely identical, which no client can
|
||||
// observe and no precondition needs.
|
||||
// - UpdatedAt is excluded so that equal representations always yield equal
|
||||
// validators. A write that changes nothing must not invalidate a
|
||||
// precondition another client is holding.
|
||||
//
|
||||
// Everything else is in, including the identity fields and CreatedAt. A
|
||||
// validator that covered only the mutable toggles would survive a delete
|
||||
// followed by a fresh bootstrap onto the same toggle values, and an If-Match
|
||||
// held across that gap would then authorize a write against what is really a
|
||||
// different resource. CreatedAt is what distinguishes the re-bootstrapped row.
|
||||
//
|
||||
// CreatedAt is hashed at whole-second precision because the validator has to
|
||||
// agree across a store round-trip. A freshly bootstrapped row derives its
|
||||
// validator in memory, from a time.Time carrying nanoseconds, while every
|
||||
// later comparison derives it from a row read back out of the store — and the
|
||||
// engines truncate: PostgreSQL to microseconds, MySQL DATETIME to whole
|
||||
// seconds without an fsp. At nanosecond precision the two never agree again,
|
||||
// so the validator a bootstrap hands out is permanently unusable. Seconds is
|
||||
// the floor every supported engine preserves. The cost is that a delete and
|
||||
// re-bootstrap within the same second, onto the same endpoint and the same
|
||||
// toggles, derives the same validator; a labeled bootstrap draws a fresh
|
||||
// random label, so that needs a self-addressed endpoint reclaimed inside one
|
||||
// second.
|
||||
//
|
||||
// Adding a field to Settings means deciding whether it belongs here; the
|
||||
// field-count guard in the tests is what forces that decision.
|
||||
func (s *Settings) ETag() string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%t\x00%t\x00%t\x00%d\x00%d",
|
||||
s.Domain,
|
||||
s.ProxyAddress,
|
||||
s.EnableLogCollection,
|
||||
s.EnablePromptCollection,
|
||||
s.RedactPii,
|
||||
s.AccessLogRetentionDays,
|
||||
s.CreatedAt.Unix(),
|
||||
)
|
||||
return hex.EncodeToString(h.Sum(nil))[:etagLength]
|
||||
}
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at — the
|
||||
// Domain column. Empty until the row is bootstrapped.
|
||||
func (s *Settings) Endpoint() string { return s.Domain }
|
||||
|
||||
// Dedicated reports whether the account's gateway is served by a proxy
|
||||
// dedicated to it — the self-addressed shape, where the serving proxy declares
|
||||
// the endpoint hostname itself. The alternative (labeled) shape has the
|
||||
// endpoint one label beneath a shared cluster's address.
|
||||
func (s *Settings) Dedicated() bool { return s.Domain != "" && s.Domain == s.ProxyAddress }
|
||||
|
||||
// ToAPIResponse renders the settings as the API representation. The
|
||||
// timestamps are omitted while zero — a default (not yet bootstrapped) view
|
||||
// has no persisted row to date.
|
||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
retention := s.AccessLogRetentionDays
|
||||
resp := &api.AgentNetworkSettings{
|
||||
Cluster: s.Cluster,
|
||||
Subdomain: s.Subdomain,
|
||||
Endpoint: s.Endpoint(),
|
||||
ProxyAddress: s.ProxyAddress,
|
||||
Dedicated: s.Dedicated(),
|
||||
EnableLogCollection: s.EnableLogCollection,
|
||||
EnablePromptCollection: s.EnablePromptCollection,
|
||||
RedactPii: s.RedactPii,
|
||||
@@ -90,19 +162,91 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
return resp
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request onto the receiver. The mutable
|
||||
// collection fields are always replaced with the request values. Cluster
|
||||
// participates only in bootstrap and the immutability check (see
|
||||
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
|
||||
// from a request.
|
||||
// FromAPIRequest applies the update request onto the receiver: every mutable
|
||||
// field is replaced with the request value, and the identity fields (Domain,
|
||||
// ProxyAddress) carry the request's echo of the assigned values. The identity
|
||||
// fields are never written to the stored row — UpdateSettings compares them
|
||||
// against it and rejects the request when they differ, so PUT keeps the
|
||||
// house convention of requiring every field while the endpoint and proxy
|
||||
// address stay immutable.
|
||||
//
|
||||
// Every field is required by the schema, so none is presence-sensitive.
|
||||
// AccessLogRetentionDays in particular must stay required: the caller receives
|
||||
// a zero-valued Settings, and UpdateSettings copies each field onto the stored
|
||||
// row unconditionally, so an omitted value would be written as 0 — which the
|
||||
// API documents as "keep indefinitely". Making retention optional would
|
||||
// therefore let a client silently maximise log retention by leaving it out.
|
||||
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
||||
if req.Cluster != nil {
|
||||
s.Cluster = strings.TrimSpace(*req.Cluster)
|
||||
}
|
||||
s.Domain = req.Endpoint
|
||||
s.ProxyAddress = req.ProxyAddress
|
||||
s.EnableLogCollection = req.EnableLogCollection
|
||||
s.EnablePromptCollection = req.EnablePromptCollection
|
||||
s.RedactPii = req.RedactPii
|
||||
s.AccessLogRetentionDays = req.AccessLogRetentionDays
|
||||
}
|
||||
|
||||
// FromAPICreateRequest applies the optional collection toggles of a bootstrap
|
||||
// request onto the receiver (typically DefaultSettings), leaving defaults in
|
||||
// place for omitted fields. The identity fields are resolved by the manager
|
||||
// from the request's proxy_address / endpoint, not copied here.
|
||||
func (s *Settings) FromAPICreateRequest(req *api.AgentNetworkSettingsCreateRequest) {
|
||||
if req.EnableLogCollection != nil {
|
||||
s.EnableLogCollection = *req.EnableLogCollection
|
||||
}
|
||||
if req.EnablePromptCollection != nil {
|
||||
s.EnablePromptCollection = *req.EnablePromptCollection
|
||||
}
|
||||
if req.RedactPii != nil {
|
||||
s.RedactPii = *req.RedactPii
|
||||
}
|
||||
if req.AccessLogRetentionDays != nil {
|
||||
s.AccessLogRetentionDays = *req.AccessLogRetentionDays
|
||||
}
|
||||
}
|
||||
|
||||
// maxHostnameLength is the RFC 1035 bound on a full domain name.
|
||||
const maxHostnameLength = 253
|
||||
|
||||
// NormalizeHostname lowercases and trims a caller-supplied hostname and
|
||||
// validates its shape: non-empty DNS labels of letters, digits and inner
|
||||
// hyphens, joined by single dots, within length bounds. Shapes that
|
||||
// canonicalization cannot repair — leading/trailing dots, empty labels,
|
||||
// whitespace inside the name — are rejected rather than guessed at, because
|
||||
// the value lands in an immutable column.
|
||||
func NormalizeHostname(raw string) (string, error) {
|
||||
hostname := strings.ToLower(strings.TrimSpace(raw))
|
||||
if hostname == "" {
|
||||
return "", fmt.Errorf("hostname is empty")
|
||||
}
|
||||
if len(hostname) > maxHostnameLength {
|
||||
return "", fmt.Errorf("hostname exceeds %d characters", maxHostnameLength)
|
||||
}
|
||||
for _, label := range strings.Split(hostname, ".") {
|
||||
if err := validateHostnameLabel(label); err != nil {
|
||||
return "", fmt.Errorf("invalid hostname %q: %w", hostname, err)
|
||||
}
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
func validateHostnameLabel(label string) error {
|
||||
if label == "" {
|
||||
return fmt.Errorf("empty label (leading, trailing or doubled dot)")
|
||||
}
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("label %q exceeds 63 characters", label)
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("label %q must not start or end with a hyphen", label)
|
||||
}
|
||||
for _, r := range label {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-':
|
||||
default:
|
||||
return fmt.Errorf("label %q contains invalid character %q", label, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
184
management/internals/modules/agentnetwork/types/settings_test.go
Normal file
184
management/internals/modules/agentnetwork/types/settings_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// etagSettings is a fully populated settings row — every hashed field set to a
|
||||
// distinctive value — so a mutation test can flip exactly one thing at a time.
|
||||
// The timestamp carries sub-second precision on purpose: a whole-second value
|
||||
// would make the precision test below pass without proving anything.
|
||||
func etagSettings() *Settings {
|
||||
created := time.Date(2026, 8, 11, 9, 30, 0, 123456789, time.UTC)
|
||||
return &Settings{
|
||||
AccountID: "acc-1",
|
||||
Domain: "cool-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: 30,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagShape pins the wire shape of the validator: a bare
|
||||
// lowercase hex string of the documented length, with no quoting — quoting is
|
||||
// the transport layer's job, and a validator that arrived pre-quoted would be
|
||||
// double-quoted on the way out.
|
||||
func TestSettings_ETagShape(t *testing.T) {
|
||||
etag := etagSettings().ETag()
|
||||
|
||||
assert.Len(t, etag, etagLength, "the validator must be exactly etagLength characters")
|
||||
assert.NotContains(t, etag, `"`, "the derived validator must not carry its own quoting")
|
||||
for _, r := range etag {
|
||||
require.Truef(t, (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f'),
|
||||
"the validator must be lowercase hex, got %q in %q", r, etag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagIsStable covers the guarantee every conditional request
|
||||
// rests on: an unchanged row derives the same validator every time, including
|
||||
// across a fresh struct built from the same values. A validator that varied
|
||||
// per derivation would fail every If-Match and make the feature unusable.
|
||||
func TestSettings_ETagIsStable(t *testing.T) {
|
||||
s := etagSettings()
|
||||
|
||||
first := s.ETag()
|
||||
assert.Equal(t, first, s.ETag(), "repeated derivation from one value must agree")
|
||||
assert.Equal(t, first, etagSettings().ETag(), "an equal row must derive an equal validator")
|
||||
}
|
||||
|
||||
// TestSettings_ETagSensitivity is the other half of the contract: every field
|
||||
// the validator covers must actually move it. The cases are also what makes
|
||||
// the field-count guard meaningful — a new field that belongs in the tuple but
|
||||
// is missing from it has no case here, and the guard is what catches that.
|
||||
//
|
||||
// The mutations are checked to be pairwise distinct, not merely different from
|
||||
// the baseline: that is what catches an ambiguous concatenation, where moving
|
||||
// a character across a field boundary would hash identically without the
|
||||
// delimiter.
|
||||
func TestSettings_ETagSensitivity(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Settings)
|
||||
}{
|
||||
{"domain", func(s *Settings) { s.Domain = "brave-otter.eu.proxy.netbird.io" }},
|
||||
{"proxy address", func(s *Settings) { s.ProxyAddress = "us.proxy.netbird.io" }},
|
||||
{"log collection", func(s *Settings) { s.EnableLogCollection = false }},
|
||||
{"prompt collection", func(s *Settings) { s.EnablePromptCollection = false }},
|
||||
{"redact pii", func(s *Settings) { s.RedactPii = false }},
|
||||
{"retention", func(s *Settings) { s.AccessLogRetentionDays = 14 }},
|
||||
{"created at", func(s *Settings) { s.CreatedAt = s.CreatedAt.Add(time.Second) }},
|
||||
// Moving characters across the Domain/ProxyAddress boundary leaves
|
||||
// the two fields' concatenation byte-identical, so this case passes
|
||||
// only because the tuple is delimited.
|
||||
{"identity boundary shifted", func(s *Settings) {
|
||||
joined := s.Domain + s.ProxyAddress
|
||||
split := len(s.Domain) - 3
|
||||
s.Domain, s.ProxyAddress = joined[:split], joined[split:]
|
||||
}},
|
||||
}
|
||||
|
||||
baseline := etagSettings().ETag()
|
||||
seen := map[string]string{"baseline": baseline}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := etagSettings()
|
||||
tc.mutate(s)
|
||||
|
||||
etag := s.ETag()
|
||||
assert.NotEqual(t, baseline, etag, "changing %s must change the validator", tc.name)
|
||||
|
||||
if other, clash := seen[etag]; clash {
|
||||
t.Fatalf("changing %s derives the same validator as %s (%s) — the field tuple is ambiguous", tc.name, other, etag)
|
||||
}
|
||||
seen[etag] = tc.name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagExclusions pins the two deliberate omissions. AccountID is
|
||||
// the resource's identity rather than its representation. UpdatedAt is left
|
||||
// out so that a write which changes nothing observable does not invalidate a
|
||||
// precondition another client is holding — equal representations must always
|
||||
// derive equal validators.
|
||||
func TestSettings_ETagExclusions(t *testing.T) {
|
||||
baseline := etagSettings().ETag()
|
||||
|
||||
other := etagSettings()
|
||||
other.AccountID = "acc-2"
|
||||
assert.Equal(t, baseline, other.ETag(), "the account id must not reach the validator")
|
||||
|
||||
touched := etagSettings()
|
||||
touched.UpdatedAt = touched.UpdatedAt.Add(time.Hour)
|
||||
assert.Equal(t, baseline, touched.ETag(), "a write that changed nothing must not move the validator")
|
||||
}
|
||||
|
||||
// TestSettings_ETagSurvivesTimestampTruncation pins the store round-trip the
|
||||
// validator has to survive. A freshly bootstrapped row derives its validator
|
||||
// in memory, from a time.Time carrying nanoseconds; every later comparison
|
||||
// derives it from a row read back out of the store, and the engines truncate
|
||||
// on the way through — PostgreSQL to microseconds, MySQL DATETIME to whole
|
||||
// seconds without an fsp. If the hash is sensitive below its coarsest engine's
|
||||
// precision, the validator a bootstrap hands out never matches again and the
|
||||
// documented "conditional PUT without an intervening GET" is a permanent 412.
|
||||
//
|
||||
// Asserted on the type rather than through a store, so it holds without running
|
||||
// the suite against every engine. The sqlite test store preserves nanoseconds,
|
||||
// so a sqlite-only suite cannot observe the truncation at all.
|
||||
func TestSettings_ETagSurvivesTimestampTruncation(t *testing.T) {
|
||||
inMemory := etagSettings()
|
||||
require.NotZero(t, inMemory.CreatedAt.Nanosecond(), "the fixture must carry sub-second precision to prove anything")
|
||||
|
||||
for name, truncation := range map[string]time.Duration{
|
||||
"postgres (microseconds)": time.Microsecond,
|
||||
"mysql (milliseconds)": time.Millisecond,
|
||||
"mysql datetime (seconds)": time.Second,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roundTripped := etagSettings()
|
||||
roundTripped.CreatedAt = roundTripped.CreatedAt.Truncate(truncation)
|
||||
|
||||
assert.Equal(t, inMemory.ETag(), roundTripped.ETag(),
|
||||
"a validator derived before the write must still match one derived after reading the row back")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagOfDefaults covers the pre-bootstrap view, which GET serves
|
||||
// as a real representation and therefore validates like one. It must derive
|
||||
// without panicking on the zero CreatedAt, and it must not collide with a
|
||||
// bootstrapped row — otherwise an If-Match taken before bootstrap would
|
||||
// authorize a write against the row that appeared since.
|
||||
func TestSettings_ETagOfDefaults(t *testing.T) {
|
||||
defaults := DefaultSettings("acc-1").ETag()
|
||||
|
||||
assert.Len(t, defaults, etagLength, "the default view must derive a well-formed validator")
|
||||
assert.NotEqual(t, etagSettings().ETag(), defaults,
|
||||
"the unbootstrapped view must not validate as a bootstrapped row")
|
||||
}
|
||||
|
||||
// etagFieldCount is the number of fields Settings carries. ETag hashes an
|
||||
// explicit tuple rather than the struct, so a field added here is silently
|
||||
// outside the validator until someone decides otherwise — the worst kind of
|
||||
// gap, because the mechanism looks present and works for every other field.
|
||||
//
|
||||
// If this constant needs updating, that is the decision point: either add the
|
||||
// new field to ETag and give it a case in TestSettings_ETagSensitivity, or
|
||||
// record here why it stays out.
|
||||
const etagFieldCount = 9
|
||||
|
||||
// TestSettings_ETagFieldCountGuard fails when a field is added to or removed
|
||||
// from Settings, forcing the question of whether it belongs in the validator.
|
||||
func TestSettings_ETagFieldCountGuard(t *testing.T) {
|
||||
assert.Equal(t, etagFieldCount, reflect.TypeFor[Settings]().NumField(),
|
||||
"Settings gained or lost a field: decide whether it belongs in ETag(), then update etagFieldCount")
|
||||
}
|
||||
@@ -2,24 +2,28 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
agentnetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type store interface {
|
||||
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error)
|
||||
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||
@@ -311,17 +315,21 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get public cluster addresses: %w", err)
|
||||
}
|
||||
reserved, err := m.reservedGatewayAddress(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(byopAddresses)+len(publicAddresses))
|
||||
merged := make([]string, 0, len(byopAddresses)+len(publicAddresses))
|
||||
for _, addr := range byopAddresses {
|
||||
if _, ok := seen[addr]; ok {
|
||||
if _, ok := seen[addr]; ok || addr == reserved {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
merged = append(merged, addr)
|
||||
}
|
||||
for _, addr := range publicAddresses {
|
||||
if _, ok := seen[addr]; ok {
|
||||
if _, ok := seen[addr]; ok || addr == reserved {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
@@ -330,6 +338,31 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// reservedGatewayAddress returns the account's agent-network gateway address
|
||||
// when its settings pin is self-addressed — a proxy dedicated to serving
|
||||
// exactly the gateway. Dropping that address from the cluster allow list keeps
|
||||
// it from being offered as a cluster for ordinary services, and because the
|
||||
// free-domain suffix match is depth-independent, dropping the address rejects
|
||||
// every name beneath it as well as the bare one. Only the account's own
|
||||
// gateway address can ever appear in its allow list (another tenant's gateway
|
||||
// proxy is account-scoped to them), so this single-address exclusion is
|
||||
// sufficient. Returns "" when the account has no settings row or a labeled
|
||||
// (shared-cluster) pin.
|
||||
func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) (string, error) {
|
||||
settings, err := m.store.GetAgentNetworkSettings(ctx, nbstore.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
var sErr *status.Error
|
||||
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
if settings == nil || !settings.Dedicated() {
|
||||
return "", nil
|
||||
}
|
||||
return settings.ProxyAddress, nil
|
||||
}
|
||||
|
||||
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
|
||||
bestCluster := ""
|
||||
bestLen := -1
|
||||
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentnetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type mockProxyManager struct {
|
||||
@@ -55,7 +61,7 @@ func TestGetClusterAllowList_BYOPMergedWithPublic(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result)
|
||||
@@ -71,7 +77,7 @@ func TestGetClusterAllowList_DeduplicatesBYOPAndPublic(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"shared.example.com", "byop.example.com", "eu.proxy.netbird.io"}, result)
|
||||
@@ -87,7 +93,7 @@ func TestGetClusterAllowList_NoBYOP_FallbackToShared(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"eu.proxy.netbird.io", "us.proxy.netbird.io"}, result)
|
||||
@@ -100,7 +106,7 @@ func TestGetClusterAllowList_BYOPError_ReturnsError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
@@ -117,7 +123,7 @@ func TestGetClusterAllowList_PublicError_ReturnsError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
@@ -134,7 +140,7 @@ func TestGetClusterAllowList_BYOPEmptySlice_FallbackToShared(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"eu.proxy.netbird.io"}, result)
|
||||
@@ -150,8 +156,138 @@ func TestGetClusterAllowList_PublicEmpty_BYOPOnly(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{proxyManager: pm}
|
||||
mgr := Manager{store: &stubStore{}, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"byop.example.com"}, result)
|
||||
}
|
||||
|
||||
// stubStore satisfies the manager's narrow store interface for allow-list
|
||||
// tests. Only the agent-network settings lookup participates; the default (a
|
||||
// nil func) reads as "no settings row", the state most accounts are in.
|
||||
type stubStore struct {
|
||||
getAgentNetworkSettingsFunc func(ctx context.Context, accountID string) (*agentnetworkTypes.Settings, error)
|
||||
}
|
||||
|
||||
func (s *stubStore) GetAccount(context.Context, string) (*types.Account, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) GetAgentNetworkSettings(ctx context.Context, _ nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error) {
|
||||
if s.getAgentNetworkSettingsFunc != nil {
|
||||
return s.getAgentNetworkSettingsFunc(ctx, accountID)
|
||||
}
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
|
||||
}
|
||||
|
||||
func (s *stubStore) GetCustomDomain(context.Context, string, string) (*domain.Domain, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) ListCustomDomains(context.Context, string) ([]*domain.Domain, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) CreateCustomDomain(context.Context, string, string, string, bool) (*domain.Domain, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) UpdateCustomDomain(context.Context, string, *domain.Domain) (*domain.Domain, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) DeleteCustomDomain(context.Context, string, string) error {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
// TestGetClusterAllowList_DedicatedGatewayAddressExcluded pins invariant (B)'s
|
||||
// chokepoint: a self-addressed settings pin reserves the account's gateway
|
||||
// address, so it is dropped from the allow list — which, because the
|
||||
// free-domain suffix match is depth-independent, rejects every name beneath
|
||||
// it as well as the bare one. Other addresses are unaffected.
|
||||
func TestGetClusterAllowList_DedicatedGatewayAddressExcluded(t *testing.T) {
|
||||
pm := &mockProxyManager{
|
||||
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{"brave-otter.gateway.example.com", "byop.example.com"}, nil
|
||||
},
|
||||
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
|
||||
return []string{"eu.proxy.netbird.io"}, nil
|
||||
},
|
||||
}
|
||||
st := &stubStore{
|
||||
getAgentNetworkSettingsFunc: func(_ context.Context, accountID string) (*agentnetworkTypes.Settings, error) {
|
||||
assert.Equal(t, "acc-123", accountID,
|
||||
"the exclusion must look up the requesting account's own settings")
|
||||
return &agentnetworkTypes.Settings{
|
||||
AccountID: accountID,
|
||||
Domain: "brave-otter.gateway.example.com",
|
||||
ProxyAddress: "brave-otter.gateway.example.com",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{store: st, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result,
|
||||
"the dedicated gateway address must be reserved from cluster selection")
|
||||
}
|
||||
|
||||
// TestGetClusterAllowList_LabeledPinDoesNotExclude pins the counterpart: a
|
||||
// labeled pin means the gateway rides on a shared cluster serving ordinary
|
||||
// services too, so nothing is reserved.
|
||||
func TestGetClusterAllowList_LabeledPinDoesNotExclude(t *testing.T) {
|
||||
pm := &mockProxyManager{
|
||||
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{"byop.example.com"}, nil
|
||||
},
|
||||
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
|
||||
return []string{"eu.proxy.netbird.io"}, nil
|
||||
},
|
||||
}
|
||||
st := &stubStore{
|
||||
getAgentNetworkSettingsFunc: func(_ context.Context, accountID string) (*agentnetworkTypes.Settings, error) {
|
||||
return &agentnetworkTypes.Settings{
|
||||
AccountID: accountID,
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{store: st, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result,
|
||||
"a labeled pin reserves nothing")
|
||||
}
|
||||
|
||||
// TestGetClusterAllowList_SettingsLookupError_ReturnsError pins that a store
|
||||
// outage is surfaced rather than silently treated as "nothing reserved" —
|
||||
// failing open here would offer a reserved gateway address for ordinary
|
||||
// services.
|
||||
func TestGetClusterAllowList_SettingsLookupError_ReturnsError(t *testing.T) {
|
||||
pm := &mockProxyManager{
|
||||
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{"byop.example.com"}, nil
|
||||
},
|
||||
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
|
||||
return []string{"eu.proxy.netbird.io"}, nil
|
||||
},
|
||||
}
|
||||
st := &stubStore{
|
||||
getAgentNetworkSettingsFunc: func(_ context.Context, _ string) (*agentnetworkTypes.Settings, error) {
|
||||
return nil, status.Errorf(status.Internal, "store outage")
|
||||
},
|
||||
}
|
||||
|
||||
mgr := Manager{store: st, proxyManager: pm}
|
||||
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "agent network settings")
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@ type ProxyTokenChecker interface {
|
||||
IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error)
|
||||
}
|
||||
|
||||
// ProxyConnectAuthorizer authorizes a proxy's claim to the cluster address it
|
||||
// declares at connect time. Implementations are supplied by integrations; none
|
||||
// is installed by default, so every well-formed claim is authorized — the
|
||||
// declared address is otherwise only checked for availability. token is nil
|
||||
// when the connection carries no proxy access token. A returned status error
|
||||
// is sent to the proxy unchanged; any other error is wrapped as
|
||||
// PermissionDenied.
|
||||
type ProxyConnectAuthorizer interface {
|
||||
AuthorizeProxyConnect(ctx context.Context, token *types.ProxyAccessToken, proxyID, address string) error
|
||||
}
|
||||
|
||||
// ProxyServiceServer implements the ProxyService gRPC server
|
||||
// AgentNetworkSynthesizer produces in-memory reverse-proxy services from
|
||||
// Agent Network provider/policy state for the proxy snapshot path; synthesised
|
||||
@@ -99,6 +110,9 @@ type ProxyServiceServer struct {
|
||||
// and the post-flight consumption write (RecordLLMUsage). Optional — when
|
||||
// nil both RPCs return Unimplemented.
|
||||
agentNetworkLimits AgentNetworkLimitsService
|
||||
// connectAuthorizer authorizes address claims at proxy connect time.
|
||||
// Optional — when nil every well-formed claim is authorized.
|
||||
connectAuthorizer ProxyConnectAuthorizer
|
||||
// ProxyController for service updates and cluster management
|
||||
proxyController proxy.Controller
|
||||
|
||||
@@ -262,6 +276,23 @@ func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer {
|
||||
return s.agentNetworkSynth
|
||||
}
|
||||
|
||||
// SetProxyConnectAuthorizer wires the connect-time address-claim authorizer.
|
||||
// Optional — when nil (the default) every well-formed claim is authorized,
|
||||
// which is the behavior without the hook. The modules layer injects this
|
||||
// after the proxy server is constructed, like the other setters.
|
||||
func (s *ProxyServiceServer) SetProxyConnectAuthorizer(authorizer ProxyConnectAuthorizer) {
|
||||
s.mu.Lock()
|
||||
s.connectAuthorizer = authorizer
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// proxyConnectAuthorizer returns the connect authorizer under read lock.
|
||||
func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.connectAuthorizer
|
||||
}
|
||||
|
||||
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
|
||||
// forwarding an LLM request upstream. Delegates to the agent-network selector,
|
||||
// which scores applicable policies by remaining headroom and returns the
|
||||
@@ -446,8 +477,9 @@ func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMapp
|
||||
return init, nil
|
||||
}
|
||||
|
||||
// validateProxyConnect validates the proxy ID and address, and checks cluster
|
||||
// address availability for account-scoped tokens.
|
||||
// validateProxyConnect validates the proxy ID and address, checks cluster
|
||||
// address availability for account-scoped tokens, and finally consults the
|
||||
// connect authorizer (when installed) on the address claim.
|
||||
func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
|
||||
if proxyID == "" {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
||||
@@ -467,6 +499,19 @@ func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx c
|
||||
}
|
||||
}
|
||||
|
||||
// The authorizer runs last, outside the account-scoped branch, so it also
|
||||
// sees management-wide and token-less connects. PermissionDenied keeps an
|
||||
// authorization rejection distinguishable from the AlreadyExists address
|
||||
// conflict above in proxy logs.
|
||||
if authorizer := s.proxyConnectAuthorizer(); authorizer != nil {
|
||||
if err := authorizer.AuthorizeProxyConnect(ctx, token, proxyID, address); err != nil {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return proxyConnectParams{}, err
|
||||
}
|
||||
return proxyConnectParams{}, status.Errorf(codes.PermissionDenied, "proxy connect not authorized: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return proxyConnectParams{proxyID: proxyID, address: address}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// capturingAuthorizer records the arguments of the last AuthorizeProxyConnect
|
||||
// call and returns a fixed error.
|
||||
type capturingAuthorizer struct {
|
||||
called int
|
||||
token *types.ProxyAccessToken
|
||||
proxyID string
|
||||
address string
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *capturingAuthorizer) AuthorizeProxyConnect(_ context.Context, token *types.ProxyAccessToken, proxyID, address string) error {
|
||||
a.called++
|
||||
a.token = token
|
||||
a.proxyID = proxyID
|
||||
a.address = address
|
||||
return a.err
|
||||
}
|
||||
|
||||
// authorizerServer builds a ProxyServiceServer whose proxy manager reports
|
||||
// every cluster address as available, so the authorizer is the only thing
|
||||
// standing between a claim and success.
|
||||
func authorizerServer(t *testing.T) *ProxyServiceServer {
|
||||
t.Helper()
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := proxy.NewMockManager(ctrl)
|
||||
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes()
|
||||
return &ProxyServiceServer{proxyManager: mgr}
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_NilAuthorizerUnchanged guards the no-behavior-change
|
||||
// claim: with no authorizer installed — the OSS default — a well-formed claim
|
||||
// succeeds and malformed input is rejected exactly as before the hook existed.
|
||||
func TestValidateProxyConnect_NilAuthorizerUnchanged(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
|
||||
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "proxy-1", params.proxyID)
|
||||
assert.Equal(t, "cluster.example.com", params.address)
|
||||
|
||||
_, err = s.validateProxyConnect("", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.InvalidArgument, st.Code(), "missing proxy_id must stay InvalidArgument")
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok = grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.InvalidArgument, st.Code(), "invalid address must stay InvalidArgument")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerReceivesClaim pins the hook contract: the
|
||||
// authorizer sees the presented token and the claimed proxy ID and address,
|
||||
// and an authorized claim proceeds.
|
||||
func TestValidateProxyConnect_AuthorizerReceivesClaim(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "cluster.example.com", params.address)
|
||||
|
||||
require.Equal(t, 1, auth.called, "authorizer must be consulted exactly once per connect")
|
||||
assert.Equal(t, "proxy-1", auth.proxyID)
|
||||
assert.Equal(t, "cluster.example.com", auth.address)
|
||||
require.NotNil(t, auth.token, "the presented token must be handed to the authorizer")
|
||||
require.NotNil(t, auth.token.AccountID)
|
||||
assert.Equal(t, "acc-1", *auth.token.AccountID)
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_PlainErrorBecomesPermissionDenied pins the error
|
||||
// mapping: a non-status error from the authorizer surfaces as
|
||||
// PermissionDenied — distinguishable from the AlreadyExists used for address
|
||||
// conflicts — and the claim does not proceed even though the address itself
|
||||
// was available.
|
||||
func TestValidateProxyConnect_PlainErrorBecomesPermissionDenied(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
s.SetProxyConnectAuthorizer(&capturingAuthorizer{err: errors.New("not the assigned credential")})
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.PermissionDenied, st.Code())
|
||||
assert.Contains(t, st.Message(), "not the assigned credential", "the authorizer's reason must survive into the status message")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_StatusErrorPassesThrough pins that an authorizer
|
||||
// which chooses its own status code is not second-guessed.
|
||||
func TestValidateProxyConnect_StatusErrorPassesThrough(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
s.SetProxyConnectAuthorizer(&capturingAuthorizer{
|
||||
err: grpcstatus.Errorf(codes.ResourceExhausted, "try later"),
|
||||
})
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.ResourceExhausted, st.Code(), "a status error must pass through unchanged")
|
||||
assert.Equal(t, "try later", st.Message())
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects pins the
|
||||
// call-site placement: the authorizer sits outside the account-scoped branch,
|
||||
// so management-wide tokens (AccountID == nil) and connections without any
|
||||
// token are also presented to it rather than bypassing policy.
|
||||
func TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects(t *testing.T) {
|
||||
s := &ProxyServiceServer{} // no proxy manager: neither path may reach the availability check
|
||||
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", globalCtx())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, auth.called, "a management-wide token must still be presented to the authorizer")
|
||||
require.NotNil(t, auth.token)
|
||||
assert.Nil(t, auth.token.AccountID)
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, auth.called, "a token-less connect must still be presented to the authorizer")
|
||||
assert.Nil(t, auth.token, "no token in context must surface as a nil token, not a zero value")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerRunsLast pins the ordering: input
|
||||
// validation and the availability check precede policy, so the authorizer is
|
||||
// never consulted about a claim that is malformed or already rejected.
|
||||
func TestValidateProxyConnect_AuthorizerRunsLast(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := proxy.NewMockManager(ctrl)
|
||||
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil)
|
||||
s := &ProxyServiceServer{proxyManager: mgr}
|
||||
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
assert.Zero(t, auth.called, "a malformed address must be rejected before policy runs")
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.AlreadyExists, st.Code(), "an address conflict must keep its own status")
|
||||
assert.Zero(t, auth.called, "a conflicting address must be rejected before policy runs")
|
||||
}
|
||||
@@ -281,6 +281,9 @@ const (
|
||||
// AccountMetricsPushDisabled indicates that a user disabled metrics push for the account
|
||||
AccountMetricsPushDisabled Activity = 141
|
||||
|
||||
// AgentNetworkSettingsDeleted indicates that a user deleted the Agent Network account settings, releasing the endpoint
|
||||
AgentNetworkSettingsDeleted Activity = 142
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -453,6 +456,7 @@ var activityMap = map[Activity]Code{
|
||||
AgentNetworkBudgetRuleDeleted: {"Agent Network budget rule deleted", "agent_network.budget_rule.delete"},
|
||||
|
||||
AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"},
|
||||
AgentNetworkSettingsDeleted: {"Agent Network settings deleted", "agent_network.settings.delete"},
|
||||
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
|
||||
@@ -68,7 +68,10 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection is the
|
||||
// GC-1 guard for UpdateSettings: it must apply the collection toggles while
|
||||
// preserving the immutable Cluster/Subdomain pinned at bootstrap.
|
||||
// preserving the immutable Domain/ProxyAddress assigned at bootstrap. The
|
||||
// request echoes the identity fields back — the PUT convention every other
|
||||
// endpoint follows — and a request echoing anything else is rejected outright
|
||||
// rather than quietly ignored.
|
||||
func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err, "createManager must succeed")
|
||||
@@ -84,7 +87,14 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
// Creating a provider bootstraps the settings row (cluster + subdomain).
|
||||
// Bootstrap is an explicit settings create; providers have no settings
|
||||
// side effects anymore.
|
||||
before, err := mgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the row")
|
||||
require.Equal(t, clusterAddr, before.ProxyAddress, "proxy address pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Domain, "endpoint allocated at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
@@ -93,43 +103,64 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
APIKey: "sk-test",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
require.NoError(t, err, "CreateProvider must bootstrap settings")
|
||||
|
||||
before, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed after bootstrap")
|
||||
require.Equal(t, clusterAddr, before.Cluster, "cluster pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
// A cluster different from the one pinned at bootstrap must be rejected
|
||||
// outright — never silently swapped or ignored.
|
||||
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: "attacker.cluster",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
// Flipping the toggles works with the pinned cluster echoed back (and
|
||||
// with it omitted); the subdomain is never taken from the request.
|
||||
// Flipping the toggles works when the request echoes the assigned
|
||||
// identity. Retention is echoed too: UpdateSettings takes it verbatim, so
|
||||
// omitting it would zero the account's retention.
|
||||
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: clusterAddr,
|
||||
Subdomain: "evil",
|
||||
Domain: before.Domain,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
})
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
}, nil)
|
||||
require.NoError(t, err, "UpdateSettings must succeed")
|
||||
assert.Equal(t, before.Cluster, updated.Cluster, "cluster is immutable and must be preserved")
|
||||
assert.Equal(t, before.Subdomain, updated.Subdomain, "subdomain is immutable and must be preserved")
|
||||
assert.Equal(t, before.Domain, updated.Domain, "domain is immutable and must be preserved")
|
||||
assert.Equal(t, before.ProxyAddress, updated.ProxyAddress, "proxy address is immutable and must be preserved")
|
||||
assert.True(t, updated.EnableLogCollection, "log collection toggle must apply")
|
||||
assert.True(t, updated.EnablePromptCollection, "prompt collection toggle must apply")
|
||||
assert.True(t, updated.RedactPii, "redact toggle must apply")
|
||||
assert.Equal(t, before.AccessLogRetentionDays, updated.AccessLogRetentionDays, "echoed retention must survive")
|
||||
|
||||
// Neither identity field can be smuggled into the row: a hand-rolled
|
||||
// Settings value carrying a different endpoint or proxy address is
|
||||
// rejected, not silently ignored.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
domain string
|
||||
proxyAddress string
|
||||
}{
|
||||
{name: "foreign endpoint", domain: "evil.example.com", proxyAddress: before.ProxyAddress},
|
||||
{name: "foreign proxy address", domain: before.Domain, proxyAddress: "attacker.cluster"},
|
||||
{name: "empty identity echo", domain: "", proxyAddress: ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Domain: tc.domain,
|
||||
ProxyAddress: tc.proxyAddress,
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: false,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
}, nil)
|
||||
assert.Error(t, err, "a mismatched identity echo must be rejected")
|
||||
assert.ErrorContains(t, err, "immutable", "the rejection must name the immutability rule")
|
||||
})
|
||||
}
|
||||
|
||||
// The rejected updates left the row exactly as the accepted one wrote it.
|
||||
afterRejects, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed")
|
||||
assert.True(t, afterRejects.EnablePromptCollection, "a rejected update must not roll back the accepted toggles")
|
||||
|
||||
reloaded, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Cluster, reloaded.Cluster, "persisted cluster unchanged")
|
||||
assert.Equal(t, before.Domain, reloaded.Domain, "persisted domain unchanged")
|
||||
assert.Equal(t, before.ProxyAddress, reloaded.ProxyAddress, "persisted proxy address unchanged")
|
||||
assert.True(t, reloaded.EnablePromptCollection, "persisted prompt collection toggled on")
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
_, err = agentMgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the endpoint")
|
||||
// The bootstrap itself reconciles and queues updates on both channels;
|
||||
// drain them so the fan-out assertions below can only be satisfied by the
|
||||
// operation under test, not by this leftover.
|
||||
drain(clientCh)
|
||||
drain(proxyCh)
|
||||
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
@@ -100,7 +108,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
APIKey: "sk-test-key",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
})
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
policy, err := agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
isValidChildAccount,
|
||||
)
|
||||
|
||||
corsMiddleware := cors.AllowAll()
|
||||
corsMiddleware := newCORSMiddleware()
|
||||
|
||||
metricsMiddleware := appMetrics.HTTPMiddleware()
|
||||
|
||||
@@ -145,3 +145,32 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
// newCORSMiddleware builds the API's CORS policy: cors.AllowAll() plus ETag in
|
||||
// ExposedHeaders.
|
||||
//
|
||||
// The addition is what makes conditional requests usable from a browser. A
|
||||
// response header that is not CORS-safelisted is invisible to JavaScript
|
||||
// unless it is named in Access-Control-Expose-Headers, and ETag is not on that
|
||||
// list — so without this the server can hand a browser client a validator it
|
||||
// has no way to read, leaving conditional requests to non-browser clients
|
||||
// only. If-Match needs nothing further, since AllowedHeaders is already "*".
|
||||
//
|
||||
// Everything else mirrors cors.AllowAll() exactly. It is spelled out rather
|
||||
// than called because the library offers no way to extend it.
|
||||
func newCORSMiddleware() *cors.Cors {
|
||||
return cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{
|
||||
http.MethodHead,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
},
|
||||
AllowedHeaders: []string{"*"},
|
||||
ExposedHeaders: []string{"ETag"},
|
||||
AllowCredentials: false,
|
||||
})
|
||||
}
|
||||
|
||||
88
management/server/http/handler_test.go
Normal file
88
management/server/http/handler_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCORSExposesETag pins the reason this policy is spelled out instead of
|
||||
// being cors.AllowAll(). ETag is not a CORS-safelisted response header, so
|
||||
// without it named in Access-Control-Expose-Headers a browser client is handed
|
||||
// a validator it cannot read — conditional requests would work for the CLI,
|
||||
// the REST client and Terraform, and silently not for the dashboard.
|
||||
//
|
||||
// Collapsing this back to cors.AllowAll() is exactly the simplification that
|
||||
// would reintroduce that, which is what this test is here to catch.
|
||||
func TestCORSExposesETag(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent-network/settings", nil)
|
||||
req.Header.Set("Origin", "https://app.netbird.io")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Compared canonicalized: the library normalizes the name it echoes, so
|
||||
// this reads "Etag" rather than "ETag". Browsers match the exposed-header
|
||||
// list case-insensitively, so the spelling does not matter — but asserting
|
||||
// it byte-exactly would fail for a reason that has nothing to do with the
|
||||
// behaviour being pinned.
|
||||
assert.Equal(t, http.CanonicalHeaderKey("ETag"),
|
||||
http.CanonicalHeaderKey(rec.Header().Get("Access-Control-Expose-Headers")),
|
||||
"browser clients must be allowed to read the validator they are sent")
|
||||
}
|
||||
|
||||
// TestCORSAllowsIfMatchPreflight covers the request half. It needs nothing
|
||||
// beyond the wildcard AllowedHeaders that was already there, so this is a
|
||||
// regression guard rather than a new grant: narrowing AllowedHeaders to a list
|
||||
// later must not drop If-Match and leave writes readable but not conditional.
|
||||
func TestCORSAllowsIfMatchPreflight(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/agent-network/settings", nil)
|
||||
req.Header.Set("Origin", "https://app.netbird.io")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPut)
|
||||
req.Header.Set("Access-Control-Request-Headers", "If-Match")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "If-Match",
|
||||
"a conditional write must survive preflight")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), http.MethodPut,
|
||||
"the conditional write's method must survive preflight")
|
||||
}
|
||||
|
||||
// TestCORSMatchesAllowAllOtherwise pins the rest of the policy, which is a
|
||||
// verbatim copy of cors.AllowAll(). Spelling the options out is what let ETag
|
||||
// be added; it also means a change to the library's defaults no longer reaches
|
||||
// this API, so the settings that matter are asserted here rather than assumed.
|
||||
func TestCORSMatchesAllowAllOtherwise(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/peers", nil)
|
||||
req.Header.Set("Origin", "https://anywhere.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodDelete)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"), "any origin must still be allowed")
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"),
|
||||
"credentials must stay disallowed — allowing them alongside a wildcard origin would be a real weakening")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), http.MethodDelete,
|
||||
"the full method set must still be allowed")
|
||||
}
|
||||
112
management/server/migration/migration_agentnetwork.go
Normal file
112
management/server/migration/migration_agentnetwork.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// agentNetworkSettingsMigration is a local view of the agent_network_settings
|
||||
// table spanning both the legacy identity columns (cluster, subdomain) and
|
||||
// their replacement (domain, proxy_address), so the migrator can address all
|
||||
// four during the reshape without importing the current model.
|
||||
type agentNetworkSettingsMigration struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (agentNetworkSettingsMigration) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// MigrateAgentNetworkSettingsToDomain reshapes agent_network_settings from the
|
||||
// legacy (cluster, subdomain) identity columns to (domain, proxy_address):
|
||||
// domain becomes `<subdomain>.<cluster>` — the endpoint hostname the old
|
||||
// columns derived — and proxy_address becomes the cluster address, preserving
|
||||
// which proxy serves the account. Runs before AutoMigrate, which then creates
|
||||
// the unique index on the freshly backfilled domain column.
|
||||
//
|
||||
// A legacy row missing either half cannot be given an endpoint; the old
|
||||
// bootstrap always wrote both, so such a row indicates corruption and the
|
||||
// migration fails loudly rather than leaving an empty domain to collide with
|
||||
// the unique index confusingly.
|
||||
//
|
||||
// The transaction is real only on sqlite and postgres, where DDL is
|
||||
// transactional. MySQL implicitly commits around every ALTER TABLE, so there
|
||||
// each step stands alone; what makes an interrupted run resumable on MySQL is
|
||||
// that every step is guarded by the schema state it changes — the entry check
|
||||
// fires while either legacy column remains, the adds skip existing columns,
|
||||
// the backfill and its loud-failure check run only while the legacy cluster
|
||||
// column exists (they provably completed before any drop), and each drop
|
||||
// skips what is already gone.
|
||||
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
|
||||
model := &agentNetworkSettingsMigration{}
|
||||
migrator := db.Migrator()
|
||||
|
||||
if !migrator.HasTable(model) {
|
||||
return nil
|
||||
}
|
||||
hasCluster := migrator.HasColumn(model, "cluster")
|
||||
if !hasCluster && !migrator.HasColumn(model, "subdomain") {
|
||||
// Fresh schema or already migrated — nothing to reshape.
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
txMigrator := tx.Migrator()
|
||||
for _, field := range []string{"Domain", "ProxyAddress"} {
|
||||
if !txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.AddColumn(model, field); err != nil {
|
||||
return fmt.Errorf("add %s column to agent_network_settings: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasCluster {
|
||||
concat := "subdomain || '.' || cluster"
|
||||
if tx.Name() == "mysql" {
|
||||
concat = "CONCAT(subdomain, '.', cluster)"
|
||||
}
|
||||
res := tx.Exec(fmt.Sprintf(
|
||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
concat,
|
||||
))
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
||||
}
|
||||
|
||||
var unmigratable int64
|
||||
if err := tx.Model(model).Where("domain IS NULL OR domain = ''").Count(&unmigratable).Error; err != nil {
|
||||
return fmt.Errorf("count unmigratable agent_network_settings rows: %w", err)
|
||||
}
|
||||
if unmigratable > 0 {
|
||||
return fmt.Errorf(
|
||||
"%d agent_network_settings row(s) have no cluster/subdomain to derive an endpoint from; resolve them manually before upgrading",
|
||||
unmigratable,
|
||||
)
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
log.WithContext(ctx).Infof("migrated %d agent_network_settings row(s) to domain/proxy_address", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
if txMigrator.HasIndex(model, "idx_agent_network_settings_cluster_subdomain") {
|
||||
if err := txMigrator.DropIndex(model, "idx_agent_network_settings_cluster_subdomain"); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings index: %w", err)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"Cluster", "Subdomain"} {
|
||||
if txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.DropColumn(model, field); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings column %s: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -736,3 +736,125 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
||||
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
||||
}
|
||||
|
||||
// legacyAgentNetworkSettings is the pre-reshape schema: identity carried as
|
||||
// (cluster, subdomain) instead of (domain, proxy_address).
|
||||
type legacyAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
EnableLogCollection bool
|
||||
}
|
||||
|
||||
func (legacyAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns pins
|
||||
// the reshape: domain becomes `<subdomain>.<cluster>`, proxy_address becomes
|
||||
// the cluster, the legacy columns are dropped, and non-identity fields ride
|
||||
// through untouched.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-1", Cluster: "eu.proxy.netbird.io", Subdomain: "violet", EnableLogCollection: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-2", Cluster: "us.proxy.netbird.io", Subdomain: "violet",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}),
|
||||
"AutoMigrate must create the domain unique index over the backfilled values")
|
||||
|
||||
var one, two agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&one, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", one.Domain, "domain must combine subdomain and cluster")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", one.ProxyAddress, "proxy address must carry the cluster")
|
||||
assert.True(t, one.EnableLogCollection, "non-identity fields must ride through")
|
||||
require.NoError(t, db.First(&two, "account_id = ?", "acct-2").Error)
|
||||
assert.Equal(t, "violet.us.proxy.netbird.io", two.Domain,
|
||||
"duplicate labels on different clusters are distinct hostnames and must both survive")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "cluster"), "legacy cluster column must be dropped")
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "subdomain"), "legacy subdomain column must be dropped")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated proves the
|
||||
// migration is safe to re-run: with no legacy column present it is a no-op.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-1", Domain: "gw.example.com", ProxyAddress: "gw.example.com",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"running against an already-migrated table must be a no-op, not an error")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "gw.example.com", row.Domain, "migrated rows must be untouched")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow pins the
|
||||
// loud-failure contract: a legacy row missing its identity halves cannot be
|
||||
// given an endpoint, and silently leaving an empty domain would collide with
|
||||
// the unique index confusingly later.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-broken", Cluster: "", Subdomain: "",
|
||||
}).Error)
|
||||
|
||||
err := migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
require.Error(t, err, "a row with no identity to derive an endpoint from must fail the migration")
|
||||
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
|
||||
}
|
||||
|
||||
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can
|
||||
// be interrupted in: DDL auto-commits there, so a crash between the two legacy
|
||||
// column drops leaves subdomain behind while cluster (and the completed
|
||||
// backfill) are already committed.
|
||||
type partialAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL
|
||||
// resumability: a rerun over the interrupted state must remove the leftover
|
||||
// subdomain column without re-running the backfill (the cluster column that
|
||||
// feeds it is gone) and without touching the migrated values.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&partialAgentNetworkSettings{
|
||||
AccountID: "acct-1", Subdomain: "violet",
|
||||
Domain: "violet.eu.proxy.netbird.io", ProxyAddress: "eu.proxy.netbird.io",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"a rerun over a partially-dropped schema must resume, not error")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&partialAgentNetworkSettings{}, "subdomain"),
|
||||
"the leftover legacy column must be dropped on resume")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", row.Domain, "migrated values must be untouched")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", row.ProxyAddress, "migrated values must be untouched")
|
||||
}
|
||||
|
||||
@@ -6340,6 +6340,30 @@ func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// HasActiveProxyAtClusterAddress reports whether any proxy — shared or
|
||||
// account-scoped — is currently active at the given cluster address, using
|
||||
// the same connected-within-threshold window as the other active-proxy
|
||||
// queries. Backs the agent-network settings delete guard: settings cannot be
|
||||
// deleted while a proxy declares the endpoint hostname as its address.
|
||||
//
|
||||
// The comparison folds case on both sides: the caller passes a normalized
|
||||
// (lowercase) hostname, but proxies declare their cluster address verbatim
|
||||
// and Connect stores it unchanged, so on case-sensitive collations a proxy
|
||||
// declaring "GW.Example.com" would otherwise slip past the guard. Hostnames
|
||||
// are case-insensitive per RFC 4343; the guard must be too.
|
||||
func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&proxy.Proxy{}).
|
||||
Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", clusterAddress, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to count active proxies at cluster address: %v", result.Error)
|
||||
return false, status.Errorf(status.Internal, "failed to count active proxies at cluster address")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
|
||||
@@ -315,25 +315,65 @@ func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByCluster returns every Settings row pinned to
|
||||
// the given proxy cluster. Used by the bootstrap label generator to
|
||||
// build the set of subdomains already taken on a cluster.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) {
|
||||
// GetAgentNetworkSettingsByProxyAddress returns every Settings row whose
|
||||
// gateway is served by the proxy declaring the given cluster address. Used by
|
||||
// cluster-scoped synthesis to find the accounts a shared proxy serves.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings []*agentNetworkTypes.Settings
|
||||
result := tx.Find(&settings, "cluster = ?", cluster)
|
||||
result := tx.Find(&settings, "proxy_address = ?", proxyAddress)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by cluster from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by cluster from store")
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by proxy address from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by proxy address from store")
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByDomain resolves the single Settings row holding the
|
||||
// given endpoint hostname — a point query on the domain unique index. Returns
|
||||
// status.NotFound when no account owns the domain.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings agentNetworkTypes.Settings
|
||||
result := tx.Take(&settings, "domain = ?", domain)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings for domain %s not found", domain)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by domain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by domain from store")
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings inserts a new settings row.
|
||||
//
|
||||
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
|
||||
// returns the driver error unwrapped. Both properties are required by the
|
||||
// bootstrap allocator: an upsert would overwrite whichever row it collided
|
||||
// with, and the allocator classifies the rejection by matching the driver's
|
||||
// message — a unique violation on the account primary key means a concurrent
|
||||
// bootstrap for the same account won, and one on the domain index means the
|
||||
// hostname is taken.
|
||||
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
if err := s.db.Create(settings).Error; err != nil {
|
||||
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAgentNetworkSettings upserts the per-account Agent Network
|
||||
// settings row.
|
||||
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
@@ -346,6 +386,25 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkSettings removes the per-account Agent Network settings
|
||||
// row, releasing the account's endpoint. Returns status.NotFound when no row
|
||||
// exists. The guards on the delete (no providers, no proxy actively serving
|
||||
// the endpoint) live in the manager, which runs this inside a transaction
|
||||
// after re-checking them under a row lock.
|
||||
func (s *SqlStore) DeleteAgentNetworkSettings(ctx context.Context, accountID string) error {
|
||||
result := s.db.Delete(&agentNetworkTypes.Settings{}, "account_id = ?", accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete agent network settings from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete agent network settings from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption atomically upserts the consumption
|
||||
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
|
||||
// and adds the supplied deltas. Concurrent calls from multiple proxy
|
||||
|
||||
@@ -88,9 +88,9 @@ func TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip(t *testing.T)
|
||||
|
||||
const accountID = "acc-settings-toggles"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: "eu.proxy.netbird.io",
|
||||
Subdomain: "violet",
|
||||
AccountID: accountID,
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
}))
|
||||
|
||||
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID)
|
||||
|
||||
@@ -328,6 +328,7 @@ type Store interface {
|
||||
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
|
||||
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
|
||||
IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error)
|
||||
HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error)
|
||||
DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error
|
||||
|
||||
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
|
||||
@@ -360,8 +361,11 @@ type Store interface {
|
||||
DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
|
||||
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*agentNetworkTypes.Settings, error)
|
||||
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
DeleteAgentNetworkSettings(ctx context.Context, accountID string) error
|
||||
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
|
||||
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
|
||||
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
|
||||
@@ -608,6 +612,9 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.BackfillPublicIDs[posture.Checks](ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,20 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateAgentNetworkSettings", ctx, settings)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings.
|
||||
func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkUsage mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -493,6 +507,20 @@ func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, prov
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID)
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkSettings mocks base method.
|
||||
func (m *MockStore) DeleteAgentNetworkSettings(ctx context.Context, accountID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAgentNetworkSettings", ctx, accountID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkSettings indicates an expected call of DeleteAgentNetworkSettings.
|
||||
func (mr *MockStoreMockRecorder) DeleteAgentNetworkSettings(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkSettings), ctx, accountID)
|
||||
}
|
||||
|
||||
// DeleteCustomDomain mocks base method.
|
||||
func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1687,19 +1715,34 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, acco
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID)
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByCluster mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*types.Settings, error) {
|
||||
// GetAgentNetworkSettingsByDomain mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*types.Settings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster)
|
||||
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByDomain", ctx, lockStrength, domain)
|
||||
ret0, _ := ret[0].(*types.Settings)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByDomain indicates an expected call of GetAgentNetworkSettingsByDomain.
|
||||
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByDomain(ctx, lockStrength, domain interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByDomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByDomain), ctx, lockStrength, domain)
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByProxyAddress mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*types.Settings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByProxyAddress", ctx, lockStrength, proxyAddress)
|
||||
ret0, _ := ret[0].([]*types.Settings)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster.
|
||||
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call {
|
||||
// GetAgentNetworkSettingsByProxyAddress indicates an expected call of GetAgentNetworkSettingsByProxyAddress.
|
||||
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByProxyAddress(ctx, lockStrength, proxyAddress interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByProxyAddress", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByProxyAddress), ctx, lockStrength, proxyAddress)
|
||||
}
|
||||
|
||||
// GetAgentNetworkUsageRows mocks base method.
|
||||
@@ -2956,6 +2999,21 @@ func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, acco
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name)
|
||||
}
|
||||
|
||||
// HasActiveProxyAtClusterAddress mocks base method.
|
||||
func (m *MockStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasActiveProxyAtClusterAddress", ctx, clusterAddress)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasActiveProxyAtClusterAddress indicates an expected call of HasActiveProxyAtClusterAddress.
|
||||
func (mr *MockStoreMockRecorder) HasActiveProxyAtClusterAddress(ctx, clusterAddress interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasActiveProxyAtClusterAddress", reflect.TypeOf((*MockStore)(nil).HasActiveProxyAtClusterAddress), ctx, clusterAddress)
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption mocks base method.
|
||||
func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -73,8 +73,8 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
|
||||
testAdminUser = "user-admin-1"
|
||||
adminGroupID = "grp-admins"
|
||||
providerID = "prov-openai-test"
|
||||
cluster = "test.proxy.local"
|
||||
subdomain = "fullchain"
|
||||
domain = "fullchain.test.proxy.local"
|
||||
proxyAddress = "test.proxy.local"
|
||||
)
|
||||
testLogger := log.New()
|
||||
testLogger.SetLevel(log.PanicLevel) // keep test output clean
|
||||
@@ -127,8 +127,8 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
|
||||
// increments on the response leg.
|
||||
require.NoError(t, st.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: cluster,
|
||||
Subdomain: subdomain,
|
||||
Domain: domain,
|
||||
ProxyAddress: proxyAddress,
|
||||
EnablePromptCollection: true,
|
||||
EnableLogCollection: true,
|
||||
RedactPii: true,
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
@@ -57,10 +59,9 @@ func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// CreateProvider creates a new Agent Network provider. Set
|
||||
// request.BootstrapCluster on the account's first provider to bootstrap the
|
||||
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
|
||||
// with a cluster).
|
||||
// CreateProvider creates a new Agent Network provider. Providers have no
|
||||
// settings side effects — bootstrap the account's gateway endpoint separately
|
||||
// via CreateSettings.
|
||||
func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
@@ -329,53 +330,157 @@ func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSettings gets the account's Agent Network gateway settings (cluster,
|
||||
// subdomain, endpoint, collection toggles). An account that has not been
|
||||
// bootstrapped yet — via UpdateSettings with a cluster, or by creating the
|
||||
// first provider with bootstrap_cluster set — reads as the defaults with an
|
||||
// empty Cluster, Subdomain and Endpoint. Management servers prior to that
|
||||
// contract answered 200 with a JSON null body instead; that legacy shape is
|
||||
// translated to an APIError matchable via IsNotFound rather than fabricating
|
||||
// defaults the server never stated.
|
||||
// GetSettings gets the account's Agent Network gateway settings (endpoint,
|
||||
// proxy address, collection toggles). An account that has not been
|
||||
// bootstrapped yet — via CreateSettings — reads as the defaults with an empty
|
||||
// Endpoint and ProxyAddress. Management servers prior to that contract
|
||||
// answered 200 with a JSON null body instead; that legacy shape is translated
|
||||
// to an APIError matchable via IsNotFound rather than fabricating defaults
|
||||
// the server never stated.
|
||||
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.GetSettingsWithETag(ctx)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// GetSettingsWithETag is GetSettings, additionally returning the entity-tag
|
||||
// the server derived for the settings it returned. Hand that validator to
|
||||
// UpdateSettingsIfMatch or DeleteSettingsIfMatch to make the write conditional
|
||||
// on nothing having changed in between — the read-modify-write cycle that
|
||||
// otherwise silently reverts a concurrent change.
|
||||
func (a *AgentNetworkAPI) GetSettingsWithETag(ctx context.Context) (*api.AgentNetworkSettings, string, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||
return nil, "", &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||
}
|
||||
var ret api.AgentNetworkSettings
|
||||
if err := json.Unmarshal(body, &ret); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
return &ret, nil
|
||||
return &ret, etagFrom(resp), nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates the account's Agent Network settings; the request
|
||||
// replaces every mutable field (collection toggles and retention). Setting
|
||||
// request.Cluster bootstraps the settings row when the account does not have
|
||||
// one yet; on a bootstrapped account it must match the assigned cluster (or
|
||||
// be nil) and any other value is rejected — the cluster is immutable.
|
||||
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
// CreateSettings bootstraps the account's Agent Network settings row,
|
||||
// assigning the immutable endpoint. Exactly one of request.ProxyAddress
|
||||
// (labeled endpoint beneath that cluster; the server allocates the label) and
|
||||
// request.Endpoint (self-addressed dedicated endpoint, claimed verbatim) must
|
||||
// be set. Returns a conflict when the account already has a settings row.
|
||||
func (a *AgentNetworkAPI) CreateSettings(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.CreateSettingsWithETag(ctx, request)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// CreateSettingsWithETag is CreateSettings, additionally returning the
|
||||
// entity-tag of the row it bootstrapped, so a client can follow the bootstrap
|
||||
// with a conditional write without an intervening read.
|
||||
func (a *AgentNetworkAPI) CreateSettingsWithETag(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, string, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
return &ret, err
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &ret, etagFrom(resp), nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates the account's Agent Network settings; the request
|
||||
// carries every field, replacing the mutable ones (collection toggles and
|
||||
// retention). The endpoint and proxy address are assigned at bootstrap
|
||||
// (CreateSettings) and immutable — the request must echo them unchanged, and
|
||||
// a request carrying different values is rejected. Returns not-found until
|
||||
// the account is bootstrapped.
|
||||
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.UpdateSettingsIfMatch(ctx, request, "")
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// UpdateSettingsIfMatch is UpdateSettings made conditional on etag — the
|
||||
// validator from an earlier read — still being current, and returns the
|
||||
// validator of the row it wrote. This is what closes the read-modify-write
|
||||
// window: a settings change made between the read and this write makes the
|
||||
// request fail with a precondition-failed APIError instead of reverting it.
|
||||
//
|
||||
// An empty etag sends no precondition and updates unconditionally, which is
|
||||
// what UpdateSettings does.
|
||||
func (a *AgentNetworkAPI) UpdateSettingsIfMatch(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody, etag string) (*api.AgentNetworkSettings, string, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := a.c.newRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil, ifMatchHeader(etag))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &ret, etagFrom(resp), nil
|
||||
}
|
||||
|
||||
// DeleteSettings deletes the account's Agent Network settings row, releasing
|
||||
// the endpoint. The server refuses (precondition failed) while any provider
|
||||
// exists for the account or while a proxy is actively serving the endpoint.
|
||||
// Bootstrapping again afterwards allocates a new endpoint.
|
||||
func (a *AgentNetworkAPI) DeleteSettings(ctx context.Context) error {
|
||||
return a.DeleteSettingsIfMatch(ctx, "")
|
||||
}
|
||||
|
||||
// DeleteSettingsIfMatch is DeleteSettings made conditional on etag — the
|
||||
// validator from an earlier read — still being current. Sending it matters
|
||||
// more here than on update: the server's other two refusals are about state
|
||||
// (no providers, no serving proxy), so this is the only thing that stops a
|
||||
// client working from an old read of one row from releasing the endpoint of
|
||||
// the row that replaced it.
|
||||
//
|
||||
// An empty etag sends no precondition and deletes unconditionally, which is
|
||||
// what DeleteSettings does.
|
||||
func (a *AgentNetworkAPI) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
|
||||
resp, err := a.c.newRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil, ifMatchHeader(etag))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// etagFrom returns the bare validator from a response, with the transport's
|
||||
// quoting stripped so a caller can hand it straight back to an If-Match
|
||||
// parameter without knowing the wire syntax.
|
||||
func etagFrom(resp *http.Response) string {
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`)
|
||||
}
|
||||
|
||||
// ifMatchHeader renders the precondition headers for a bare validator,
|
||||
// re-applying the quoting etagFrom stripped. An empty validator yields no
|
||||
// headers at all — an unconditional request.
|
||||
func ifMatchHeader(etag string) map[string]string {
|
||||
if etag == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"If-Match": strconv.Quote(etag)}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ var (
|
||||
}
|
||||
|
||||
testAgentNetworkSettings = api.AgentNetworkSettings{
|
||||
Cluster: "eu.proxy.netbird.io",
|
||||
Subdomain: "violet",
|
||||
Endpoint: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
Dedicated: false,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: ptr(30),
|
||||
}
|
||||
@@ -120,18 +120,15 @@ func TestAgentNetwork_CreateProvider_200(t *testing.T) {
|
||||
var req api.PostApiAgentNetworkProvidersJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
assert.Equal(t, "OpenAI", req.Name)
|
||||
require.NotNil(t, req.BootstrapCluster)
|
||||
assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-test"),
|
||||
BootstrapCluster: ptr("eu.proxy.netbird.io"),
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-test"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||
@@ -456,6 +453,45 @@ func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
reqBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var req api.PostApiAgentNetworkSettingsJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
require.NotNil(t, req.ProxyAddress, "proxy address must be on the wire")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", *req.ProxyAddress)
|
||||
assert.Nil(t, req.Endpoint, "endpoint must stay off the wire for a labeled bootstrap")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateSettings(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
ProxyAddress: ptr("eu.proxy.netbird.io"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateSettings_Conflict(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings already bootstrapped for account acct1", Code: 409})
|
||||
w.WriteHeader(409)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.CreateSettings(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: ptr("gw.example.com"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already bootstrapped")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -464,15 +500,18 @@ func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
var req api.PutApiAgentNetworkSettingsJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster)
|
||||
assert.True(t, req.EnableLogCollection)
|
||||
assert.Equal(t, "brave-otter.eu.proxy.netbird.io", req.Endpoint,
|
||||
"the identity echo must be on the wire")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", req.ProxyAddress,
|
||||
"the identity echo must be on the wire")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Cluster: ptr("eu.proxy.netbird.io"),
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -483,15 +522,160 @@ func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
|
||||
func TestAgentNetwork_UpdateSettings_Err(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
|
||||
w.WriteHeader(422)
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them", Code: 404})
|
||||
w.WriteHeader(404)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Cluster: ptr("us.proxy.netbird.io"),
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "immutable")
|
||||
assert.True(t, rest.IsNotFound(err), "an unbootstrapped account must surface as IsNotFound")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettings(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteSettings_Guarded(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings cannot be deleted while 2 provider(s) exist; delete the providers first", Code: 412})
|
||||
w.WriteHeader(412)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeleteSettings(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be deleted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_GetSettings_ETag pins that the validator surfaces to the
|
||||
// caller with the transport's quoting stripped, so it can be handed straight
|
||||
// back to a conditional write without the caller knowing the wire syntax.
|
||||
func TestAgentNetwork_GetSettings_ETag(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, etag, err := c.AgentNetwork.GetSettingsWithETag(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
assert.Equal(t, "9f86d081884c7d65", etag, "the validator must arrive unquoted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_IfMatch covers the round trip that makes the
|
||||
// whole feature usable: a validator taken from a read goes back out quoted on
|
||||
// the write, and the write's own validator comes back for the next one.
|
||||
func TestAgentNetwork_UpdateSettings_IfMatch(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, `"9f86d081884c7d65"`, r.Header.Get("If-Match"),
|
||||
"the precondition must go out quoted as a strong entity-tag")
|
||||
w.Header().Set("ETag", `"0011223344556677"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, etag, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
}, "9f86d081884c7d65")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0011223344556677", etag, "the write must return the new validator")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_NoPrecondition pins the back-compatible
|
||||
// path: the plain method sends no If-Match at all, rather than an empty or
|
||||
// wildcard one, so it stays the unconditional update it has always been.
|
||||
func TestAgentNetwork_UpdateSettings_NoPrecondition(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Empty(t, r.Header.Values("If-Match"), "an unconditional update must send no precondition")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_StalePrecondition pins how a refused write
|
||||
// reaches the caller: as an APIError a client can recognise as staleness and
|
||||
// answer by reading again, rather than as an opaque failure.
|
||||
func TestAgentNetwork_UpdateSettings_StalePrecondition(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "if-match precondition failed: the settings have changed since they were read; get them again and retry", Code: 412})
|
||||
w.WriteHeader(412)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, _, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
}, "9f86d081884c7d65")
|
||||
require.Error(t, err)
|
||||
assert.True(t, rest.IsPreconditionFailed(err), "a refused precondition must be recognisable as one")
|
||||
assert.False(t, rest.IsNotFound(err), "it must not be confused with an unbootstrapped account")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_CreateSettings_ETag pins that the bootstrap hands back a
|
||||
// validator, which is what lets a client follow it with a conditional write
|
||||
// without an intervening read.
|
||||
func TestAgentNetwork_CreateSettings_ETag(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, etag, err := c.AgentNetwork.CreateSettingsWithETag(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
ProxyAddress: ptr("eu.proxy.netbird.io"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "9f86d081884c7d65", etag, "the bootstrap must return a validator")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_DeleteSettings_IfMatch covers the conditional delete on the
|
||||
// wire, and that the plain method still sends nothing.
|
||||
func TestAgentNetwork_DeleteSettings_IfMatch(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
var seen []string
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
seen = append(seen, r.Header.Get("If-Match"))
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettingsIfMatch(context.Background(), "9f86d081884c7d65"))
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettings(context.Background()))
|
||||
assert.Equal(t, []string{`"9f86d081884c7d65"`, ""}, seen,
|
||||
"the conditional delete must carry the quoted validator and the plain one must carry nothing")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@ func IsNotFound(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPreconditionFailed returns true if the error represents a 412 Precondition
|
||||
// Failed response — an If-Match the server refused, or an endpoint's own
|
||||
// precondition. A caller that sent a conditional request can use this to tell
|
||||
// "someone else changed it, read again and retry" apart from a real failure;
|
||||
// the message distinguishes it from an endpoint's other 412s.
|
||||
func IsPreconditionFailed(err error) bool {
|
||||
var apiErr *APIError
|
||||
if ok := errors.As(err, &apiErr); ok {
|
||||
return apiErr.StatusCode == http.StatusPreconditionFailed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Client Management service HTTP REST API Client
|
||||
type Client struct {
|
||||
managementURL string
|
||||
@@ -218,6 +231,12 @@ func (c *Client) initialize() {
|
||||
|
||||
// NewRequest creates and executes new management API request
|
||||
func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, query map[string]string) (*http.Response, error) {
|
||||
return c.newRequest(ctx, method, path, body, query, nil)
|
||||
}
|
||||
|
||||
// newRequest is NewRequest with request headers, for the endpoints whose
|
||||
// contract includes one — conditional requests carrying If-Match.
|
||||
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, query, headers map[string]string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.managementURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -231,6 +250,9 @@ func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Re
|
||||
if c.userAgent != "" {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
}
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
if len(query) != 0 {
|
||||
q := req.URL.Query()
|
||||
|
||||
@@ -5208,10 +5208,6 @@ components:
|
||||
type: string
|
||||
description: Full upstream URL (with scheme) that NetBird forwards traffic to.
|
||||
example: "https://api.openai.com"
|
||||
bootstrap_cluster:
|
||||
type: string
|
||||
description: Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row.
|
||||
example: "eu.proxy.netbird.io"
|
||||
api_key:
|
||||
type: string
|
||||
description: Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key).
|
||||
@@ -6193,20 +6189,20 @@ components:
|
||||
- cache_cost_usd
|
||||
AgentNetworkSettings:
|
||||
type: object
|
||||
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
|
||||
description: Per-account Agent Network gateway settings. One row per account; endpoint and proxy_address are assigned at bootstrap (POST) and immutable thereafter. Before bootstrap the account reads as the default values with empty endpoint and proxy_address.
|
||||
properties:
|
||||
cluster:
|
||||
type: string
|
||||
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
|
||||
example: "eu.proxy.netbird.io"
|
||||
subdomain:
|
||||
type: string
|
||||
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
|
||||
example: "violet"
|
||||
endpoint:
|
||||
type: string
|
||||
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
|
||||
example: "violet.eu.proxy.netbird.io"
|
||||
description: Bare hostname agents call for this account. Empty until the account is bootstrapped.
|
||||
example: "brave-otter.eu.proxy.netbird.io"
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Declared cluster address of the proxy serving this account's gateway. Equal to `endpoint` when a dedicated proxy serves the account; otherwise the endpoint's immediate parent (a shared cluster the endpoint hangs one label beneath). Empty until the account is bootstrapped.
|
||||
example: "eu.proxy.netbird.io"
|
||||
dedicated:
|
||||
type: boolean
|
||||
description: Whether the account's gateway is served by a proxy dedicated to it (endpoint equals proxy_address).
|
||||
example: false
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
@@ -6236,19 +6232,51 @@ components:
|
||||
readOnly: true
|
||||
example: "2026-04-26T10:30:00Z"
|
||||
required:
|
||||
- cluster
|
||||
- subdomain
|
||||
- endpoint
|
||||
- proxy_address
|
||||
- dedicated
|
||||
- enable_log_collection
|
||||
- enable_prompt_collection
|
||||
- redact_pii
|
||||
AgentNetworkSettingsCreateRequest:
|
||||
type: object
|
||||
description: Bootstraps the per-account Agent Network settings row, assigning the account's immutable endpoint. Exactly one of `proxy_address` and `endpoint` must be provided. `proxy_address` requests a labeled endpoint — the server allocates a label and the endpoint becomes `<label>.<proxy_address>`, served by whichever proxy declares that parent address. `endpoint` claims the given hostname itself as a self-addressed (dedicated) endpoint, served only by a proxy declaring exactly that address — the claim is legitimate before the proxy exists (address-first). Collection toggles may ride along; omitted toggles take their defaults.
|
||||
properties:
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Cluster address to allocate a labeled endpoint beneath. Mutually exclusive with `endpoint`.
|
||||
example: "eu.proxy.netbird.io"
|
||||
endpoint:
|
||||
type: string
|
||||
description: Hostname to claim as the account's self-addressed (dedicated) endpoint. Mutually exclusive with `proxy_address`. Rejected when another account already holds it.
|
||||
example: "brave-otter.gateway.example.com"
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic. Defaults to true.
|
||||
example: true
|
||||
enable_prompt_collection:
|
||||
type: boolean
|
||||
description: Master switch for request/response prompt capture. Defaults to false.
|
||||
example: false
|
||||
redact_pii:
|
||||
type: boolean
|
||||
description: Whether captured prompts have PII redacted. Defaults to false.
|
||||
example: false
|
||||
access_log_retention_days:
|
||||
type: integer
|
||||
description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Defaults to 30.
|
||||
example: 30
|
||||
AgentNetworkSettingsRequest:
|
||||
type: object
|
||||
description: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
|
||||
description: Account-level Agent Network settings update. Every field is required, matching the PUT convention of the other endpoints. The endpoint and proxy address are assigned at bootstrap (POST) and are immutable — the request must carry them unchanged, and a request carrying different values is rejected. To change them, delete the settings (DELETE, guarded) and bootstrap again; re-creating allocates a new endpoint.
|
||||
properties:
|
||||
cluster:
|
||||
endpoint:
|
||||
type: string
|
||||
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
|
||||
description: The account's gateway endpoint hostname. Immutable — must match the assigned value; a different value is rejected.
|
||||
example: "brave-otter.eu.proxy.netbird.io"
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Declared cluster address of the proxy serving this account's gateway. Immutable — must match the assigned value; a different value is rejected.
|
||||
example: "eu.proxy.netbird.io"
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
@@ -6267,9 +6295,12 @@ components:
|
||||
description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||
example: 30
|
||||
required:
|
||||
- endpoint
|
||||
- proxy_address
|
||||
- enable_log_collection
|
||||
- enable_prompt_collection
|
||||
- redact_pii
|
||||
- access_log_retention_days
|
||||
AgentNetworkBudgetRule:
|
||||
type: object
|
||||
description: Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller.
|
||||
@@ -6407,6 +6438,15 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
example: cot7r4n3l3vh3qj4qveg
|
||||
ETag:
|
||||
description: |
|
||||
Strong entity-tag identifying the returned representation. Send it back
|
||||
in `If-Match` on a subsequent write to make that write conditional, so
|
||||
a change made between the read and the write is refused with `412`
|
||||
rather than silently overwritten.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
@@ -13694,7 +13734,7 @@ paths:
|
||||
/api/agent-network/settings:
|
||||
get:
|
||||
summary: Retrieve Agent Network settings
|
||||
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint.
|
||||
description: Returns the per-account Agent Network gateway settings (endpoint, proxy address, collection toggles). Before the account is bootstrapped via POST, the response carries the default values with an empty endpoint and proxy address.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13702,6 +13742,9 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Agent Network settings for the account
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13712,13 +13755,63 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
put:
|
||||
summary: Update Agent Network settings
|
||||
description: Updates the account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable.
|
||||
post:
|
||||
summary: Bootstrap Agent Network settings
|
||||
description: Creates the per-account Agent Network settings row and allocates the account's endpoint. Exactly one of `proxy_address` (labeled endpoint under that cluster; the server allocates the label) and `endpoint` (self-addressed dedicated endpoint, claimed verbatim) must be provided. The endpoint and proxy address are immutable once assigned. Returns 409 when the account already has a settings row.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
requestBody:
|
||||
required: true
|
||||
description: Settings bootstrap request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkSettingsCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: The freshly bootstrapped Agent Network settings
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkSettings'
|
||||
'400':
|
||||
"$ref": "#/components/responses/bad_request"
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'409':
|
||||
"$ref": "#/components/responses/conflict"
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
put:
|
||||
summary: Update Agent Network settings
|
||||
description: Updates the account-level Agent Network settings; the request carries every field, replacing the mutable ones (collection toggles and retention). Returns 404 when the account has no settings row yet — bootstrap it with POST first. The endpoint and proxy address are assigned at bootstrap and immutable; the request must carry them unchanged, and a request carrying different values is rejected. Supply `If-Match` to make the update conditional; without it the update is unconditional and the last write wins.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
parameters:
|
||||
- name: If-Match
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
Makes the update conditional on the settings not having changed since
|
||||
they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
or `*` to require only that a settings row exists. The precondition is
|
||||
evaluated against the stored row inside the update's own transaction,
|
||||
so two clients starting from the same `ETag` cannot both succeed.
|
||||
Omitting the header leaves the update unconditional.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
requestBody:
|
||||
description: Settings update request
|
||||
content:
|
||||
@@ -13728,6 +13821,9 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Updated Agent Network settings
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13740,10 +13836,48 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'412':
|
||||
description: The `If-Match` precondition failed — the settings changed since they were read. The stored settings are unmodified; read them again and retry.
|
||||
content: { }
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
delete:
|
||||
summary: Delete Agent Network settings
|
||||
description: Deletes the account's Agent Network settings row, releasing the endpoint. Guarded — the delete is refused with 412 while any Agent Network provider exists for the account or while a proxy is actively serving the endpoint. Bootstrapping again after a delete allocates a new endpoint; the released hostname is not reserved. Supply `If-Match` to make the delete conditional, which is worth doing here even more than on update — the other two guards are about state rather than staleness, so nothing else stops a client from deleting a row that was replaced since it read one.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
parameters:
|
||||
- name: If-Match
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
Makes the delete conditional on the settings not having changed since
|
||||
they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
or `*` to require only that a settings row exists. The precondition is
|
||||
evaluated inside the delete's own transaction, ahead of the provider
|
||||
and serving-proxy guards. Omitting the header leaves the delete
|
||||
unconditional.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
responses:
|
||||
'200':
|
||||
description: Settings deleted
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'412':
|
||||
description: Delete refused — the `If-Match` precondition failed, or Agent Network providers still exist for the account, or a proxy is actively serving the endpoint. The stored settings are unmodified in every case; the response message distinguishes them.
|
||||
content: { }
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/budget-rules:
|
||||
get:
|
||||
summary: List all Agent Network budget rules
|
||||
|
||||
@@ -2329,9 +2329,6 @@ type AgentNetworkProviderRequest struct {
|
||||
// ApiKey Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key).
|
||||
ApiKey *string `json:"api_key,omitempty"`
|
||||
|
||||
// BootstrapCluster Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row.
|
||||
BootstrapCluster *string `json:"bootstrap_cluster,omitempty"`
|
||||
|
||||
// Enabled Whether the provider is enabled. Defaults to true on create.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
|
||||
@@ -2363,43 +2360,61 @@ type AgentNetworkProviderRequest struct {
|
||||
UpstreamUrl string `json:"upstream_url"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
|
||||
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; endpoint and proxy_address are assigned at bootstrap (POST) and immutable thereafter. Before bootstrap the account reads as the default values with empty endpoint and proxy_address.
|
||||
type AgentNetworkSettings struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
|
||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||
|
||||
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
|
||||
Cluster string `json:"cluster"`
|
||||
|
||||
// CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped.
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
|
||||
// Dedicated Whether the account's gateway is served by a proxy dedicated to it (endpoint equals proxy_address).
|
||||
Dedicated bool `json:"dedicated"`
|
||||
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
EnableLogCollection bool `json:"enable_log_collection"`
|
||||
|
||||
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
|
||||
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
||||
|
||||
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
|
||||
// Endpoint Bare hostname agents call for this account. Empty until the account is bootstrapped.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// ProxyAddress Declared cluster address of the proxy serving this account's gateway. Equal to `endpoint` when a dedicated proxy serves the account; otherwise the endpoint's immediate parent (a shared cluster the endpoint hangs one label beneath). Empty until the account is bootstrapped.
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
|
||||
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
|
||||
Subdomain string `json:"subdomain"`
|
||||
|
||||
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
|
||||
type AgentNetworkSettingsRequest struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||
// AgentNetworkSettingsCreateRequest Bootstraps the per-account Agent Network settings row, assigning the account's immutable endpoint. Exactly one of `proxy_address` and `endpoint` must be provided. `proxy_address` requests a labeled endpoint — the server allocates a label and the endpoint becomes `<label>.<proxy_address>`, served by whichever proxy declares that parent address. `endpoint` claims the given hostname itself as a self-addressed (dedicated) endpoint, served only by a proxy declaring exactly that address — the claim is legitimate before the proxy exists (address-first). Collection toggles may ride along; omitted toggles take their defaults.
|
||||
type AgentNetworkSettingsCreateRequest struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Defaults to 30.
|
||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||
|
||||
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
|
||||
Cluster *string `json:"cluster,omitempty"`
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. Defaults to true.
|
||||
EnableLogCollection *bool `json:"enable_log_collection,omitempty"`
|
||||
|
||||
// EnablePromptCollection Master switch for request/response prompt capture. Defaults to false.
|
||||
EnablePromptCollection *bool `json:"enable_prompt_collection,omitempty"`
|
||||
|
||||
// Endpoint Hostname to claim as the account's self-addressed (dedicated) endpoint. Mutually exclusive with `proxy_address`. Rejected when another account already holds it.
|
||||
Endpoint *string `json:"endpoint,omitempty"`
|
||||
|
||||
// ProxyAddress Cluster address to allocate a labeled endpoint beneath. Mutually exclusive with `endpoint`.
|
||||
ProxyAddress *string `json:"proxy_address,omitempty"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted. Defaults to false.
|
||||
RedactPii *bool `json:"redact_pii,omitempty"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettingsRequest Account-level Agent Network settings update. Every field is required, matching the PUT convention of the other endpoints. The endpoint and proxy address are assigned at bootstrap (POST) and are immutable — the request must carry them unchanged, and a request carrying different values is rejected. To change them, delete the settings (DELETE, guarded) and bootstrap again; re-creating allocates a new endpoint.
|
||||
type AgentNetworkSettingsRequest struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||
AccessLogRetentionDays int `json:"access_log_retention_days"`
|
||||
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
EnableLogCollection bool `json:"enable_log_collection"`
|
||||
@@ -2407,6 +2422,12 @@ type AgentNetworkSettingsRequest struct {
|
||||
// EnablePromptCollection Master switch for request/response prompt capture.
|
||||
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
||||
|
||||
// Endpoint The account's gateway endpoint hostname. Immutable — must match the assigned value; a different value is rejected.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// ProxyAddress Declared cluster address of the proxy serving this account's gateway. Immutable — must match the assigned value; a different value is rejected.
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted.
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
}
|
||||
@@ -5918,6 +5939,28 @@ type GetApiAgentNetworkAccessLogsParamsSortBy string
|
||||
// GetApiAgentNetworkAccessLogsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogs.
|
||||
type GetApiAgentNetworkAccessLogsParamsSortOrder string
|
||||
|
||||
// DeleteApiAgentNetworkSettingsParams defines parameters for DeleteApiAgentNetworkSettings.
|
||||
type DeleteApiAgentNetworkSettingsParams struct {
|
||||
// IfMatch Makes the delete conditional on the settings not having changed since
|
||||
// they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
// or `*` to require only that a settings row exists. The precondition is
|
||||
// evaluated inside the delete's own transaction, ahead of the provider
|
||||
// and serving-proxy guards. Omitting the header leaves the delete
|
||||
// unconditional.
|
||||
IfMatch *string `json:"If-Match,omitempty"`
|
||||
}
|
||||
|
||||
// PutApiAgentNetworkSettingsParams defines parameters for PutApiAgentNetworkSettings.
|
||||
type PutApiAgentNetworkSettingsParams struct {
|
||||
// IfMatch Makes the update conditional on the settings not having changed since
|
||||
// they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
// or `*` to require only that a settings row exists. The precondition is
|
||||
// evaluated against the stored row inside the update's own transaction,
|
||||
// so two clients starting from the same `ETag` cannot both succeed.
|
||||
// Omitting the header leaves the update unconditional.
|
||||
IfMatch *string `json:"If-Match,omitempty"`
|
||||
}
|
||||
|
||||
// GetApiAgentNetworkUsageOverviewParams defines parameters for GetApiAgentNetworkUsageOverview.
|
||||
type GetApiAgentNetworkUsageOverviewParams struct {
|
||||
// Granularity Time bucket width. Defaults to day.
|
||||
@@ -6176,6 +6219,9 @@ type PostApiAgentNetworkProvidersJSONRequestBody = AgentNetworkProviderRequest
|
||||
// PutApiAgentNetworkProvidersProviderIdJSONRequestBody defines body for PutApiAgentNetworkProvidersProviderId for application/json ContentType.
|
||||
type PutApiAgentNetworkProvidersProviderIdJSONRequestBody = AgentNetworkProviderRequest
|
||||
|
||||
// PostApiAgentNetworkSettingsJSONRequestBody defines body for PostApiAgentNetworkSettings for application/json ContentType.
|
||||
type PostApiAgentNetworkSettingsJSONRequestBody = AgentNetworkSettingsCreateRequest
|
||||
|
||||
// PutApiAgentNetworkSettingsJSONRequestBody defines body for PutApiAgentNetworkSettings for application/json ContentType.
|
||||
type PutApiAgentNetworkSettingsJSONRequestBody = AgentNetworkSettingsRequest
|
||||
|
||||
|
||||
103
shared/management/http/util/etag.go
Normal file
103
shared/management/http/util/etag.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
etagHeader = "ETag"
|
||||
ifMatchHeader = "If-Match"
|
||||
|
||||
// matchAny is the If-Match value that matches any current representation
|
||||
// of the resource (RFC 9110 §13.1.1).
|
||||
matchAny = "*"
|
||||
|
||||
// weakPrefix marks a weak validator. If-Match is defined in terms of the
|
||||
// strong comparison function, under which a weak validator never matches.
|
||||
weakPrefix = "W/"
|
||||
)
|
||||
|
||||
// SetETag writes etag as a strong ETag response header, quoted per RFC 9110.
|
||||
// The value passed in is the bare validator — callers derive it (typically
|
||||
// from the type being served) and this applies the wire syntax, so the quoting
|
||||
// is decided in one place rather than at every handler.
|
||||
//
|
||||
// Call it before writing the body: once the response is committed the header
|
||||
// no longer reaches the client. An empty etag writes no header at all, so a
|
||||
// caller with nothing to validate against does not have to special-case it.
|
||||
func SetETag(w http.ResponseWriter, etag string) {
|
||||
if etag == "" {
|
||||
return
|
||||
}
|
||||
w.Header().Set(etagHeader, strconv.Quote(etag))
|
||||
}
|
||||
|
||||
// Precondition is a parsed If-Match request precondition. The zero value
|
||||
// matches nothing; a nil *Precondition is an unconditional request and matches
|
||||
// everything, so a handler can pass the result of IfMatch straight through
|
||||
// without a presence check.
|
||||
type Precondition struct {
|
||||
// tags are the strong entity-tags the client will accept, unquoted.
|
||||
tags []string
|
||||
|
||||
// any records the "*" form, which matches any current representation.
|
||||
any bool
|
||||
}
|
||||
|
||||
// IfMatch parses the request's If-Match precondition. It returns nil when the
|
||||
// header is absent — an unconditional request, which is the back-compatible
|
||||
// default: clients that know nothing of conditional requests keep working.
|
||||
//
|
||||
// A header that is present but carries nothing usable — empty, or nothing but
|
||||
// weak validators — yields a precondition that matches nothing rather than
|
||||
// nil. Failing closed is the only safe direction: a client that meant to send
|
||||
// a precondition must not have it silently dropped and its write let through
|
||||
// unguarded.
|
||||
func IfMatch(r *http.Request) *Precondition {
|
||||
values := r.Header.Values(ifMatchHeader)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
p := &Precondition{}
|
||||
for _, value := range values {
|
||||
for raw := range strings.SplitSeq(value, ",") {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case candidate == "":
|
||||
// Tolerated rather than rejected: a stray comma changes
|
||||
// nothing about what the client is willing to accept.
|
||||
case candidate == matchAny:
|
||||
p.any = true
|
||||
case strings.HasPrefix(candidate, weakPrefix):
|
||||
// Dropped, not unwrapped. If-Match uses strong comparison, so
|
||||
// a weak validator cannot satisfy it — and unwrapping one into
|
||||
// a strong tag would quietly grant the match the client's own
|
||||
// header said it could not have.
|
||||
default:
|
||||
p.tags = append(p.tags, strings.Trim(candidate, `"`))
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Matches reports whether etag — the bare validator of the resource as it
|
||||
// currently stands — satisfies the precondition. A nil precondition matches
|
||||
// everything.
|
||||
//
|
||||
// Callers must establish that the resource exists before consulting this: the
|
||||
// "*" form asks whether there is any current representation, a question only
|
||||
// the caller can answer, and this reports true for it.
|
||||
func (p *Precondition) Matches(etag string) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
if p.any {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(p.tags, etag)
|
||||
}
|
||||
161
shared/management/http/util/etag_test.go
Normal file
161
shared/management/http/util/etag_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSetETag covers the wire syntax: the bare validator goes in, a quoted
|
||||
// strong entity-tag comes out. Handlers pass what the type derived, so the
|
||||
// quoting has to happen here or every handler re-decides it.
|
||||
func TestSetETag(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, "9f86d081884c7d65")
|
||||
|
||||
assert.Equal(t, `"9f86d081884c7d65"`, rec.Header().Get("ETag"),
|
||||
"the validator must be emitted quoted")
|
||||
}
|
||||
|
||||
// TestSetETagEmpty pins the no-op: a caller with nothing to validate against
|
||||
// must not emit an empty entity-tag, which would be a validator that every
|
||||
// later request could match.
|
||||
func TestSetETagEmpty(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, "")
|
||||
|
||||
assert.Empty(t, rec.Header().Values("ETag"), "an empty validator must write no header")
|
||||
}
|
||||
|
||||
// TestSetETagRoundTrip closes the loop between the two halves of the helper:
|
||||
// what SetETag emits is what IfMatch accepts back. A client echoing the header
|
||||
// it was given must match, or conditional requests never succeed in practice.
|
||||
func TestSetETagRoundTrip(t *testing.T) {
|
||||
const etag = "9f86d081884c7d65"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, etag)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", rec.Header().Get("ETag"))
|
||||
|
||||
assert.True(t, IfMatch(r).Matches(etag), "an echoed ETag header must satisfy the precondition")
|
||||
}
|
||||
|
||||
// TestIfMatchAbsent pins the back-compatibility guarantee: a request with no
|
||||
// If-Match is unconditional, and the nil precondition it yields matches
|
||||
// anything so handlers need no presence check.
|
||||
func TestIfMatchAbsent(t *testing.T) {
|
||||
p := IfMatch(httptest.NewRequest(http.MethodPut, "/", nil))
|
||||
|
||||
require.Nil(t, p, "an absent header must yield no precondition")
|
||||
assert.True(t, p.Matches("9f86d081884c7d65"), "a nil precondition must match anything")
|
||||
assert.True(t, p.Matches(""), "a nil precondition must not depend on the validator")
|
||||
}
|
||||
|
||||
// TestIfMatchParsing walks the header forms a client can send. The weak and
|
||||
// unusable cases are the ones that matter: each must yield a precondition that
|
||||
// exists and refuses, never one that is absent and waves the write through.
|
||||
func TestIfMatchParsing(t *testing.T) {
|
||||
const current = "9f86d081884c7d65"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
match bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "quoted current validator",
|
||||
header: `"9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "the ordinary conditional request must be honoured",
|
||||
},
|
||||
{
|
||||
name: "unquoted current validator",
|
||||
header: "9f86d081884c7d65",
|
||||
match: true,
|
||||
reason: "a client that omits the quoting means the same thing, and only an exact value can match",
|
||||
},
|
||||
{
|
||||
name: "stale validator",
|
||||
header: `"0000000000000000"`,
|
||||
match: false,
|
||||
reason: "a validator from an earlier read must not match",
|
||||
},
|
||||
{
|
||||
name: "star",
|
||||
header: "*",
|
||||
match: true,
|
||||
reason: "* matches any current representation",
|
||||
},
|
||||
{
|
||||
name: "list containing the current validator",
|
||||
header: `"0000000000000000", "9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "If-Match is a list; any member matching is a match",
|
||||
},
|
||||
{
|
||||
name: "list of stale validators",
|
||||
header: `"0000000000000000", "1111111111111111"`,
|
||||
match: false,
|
||||
reason: "a list none of whose members match must not match",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace",
|
||||
header: ` "9f86d081884c7d65" `,
|
||||
match: true,
|
||||
reason: "list whitespace is not part of the entity-tag",
|
||||
},
|
||||
{
|
||||
name: "stray comma",
|
||||
header: `"9f86d081884c7d65", `,
|
||||
match: true,
|
||||
reason: "an empty list element says nothing about what the client accepts",
|
||||
},
|
||||
{
|
||||
name: "weak validator of the current representation",
|
||||
header: `W/"9f86d081884c7d65"`,
|
||||
match: false,
|
||||
reason: "If-Match uses strong comparison, so a weak validator never satisfies it",
|
||||
},
|
||||
{
|
||||
name: "weak validator alongside a strong one",
|
||||
header: `W/"0000000000000000", "9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "dropping the weak member must not discard the rest of the list",
|
||||
},
|
||||
{
|
||||
name: "empty header",
|
||||
header: "",
|
||||
match: false,
|
||||
reason: "a precondition the server cannot make sense of must fail closed, not vanish",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", tc.header)
|
||||
|
||||
p := IfMatch(r)
|
||||
require.NotNil(t, p, "a header that was sent must yield a precondition: %s", tc.reason)
|
||||
assert.Equal(t, tc.match, p.Matches(current), tc.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIfMatchRepeatedHeader covers the same list split across header lines,
|
||||
// which is semantically identical to the comma form and which a proxy is free
|
||||
// to produce.
|
||||
func TestIfMatchRepeatedHeader(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Add("If-Match", `"0000000000000000"`)
|
||||
r.Header.Add("If-Match", `"9f86d081884c7d65"`)
|
||||
|
||||
assert.True(t, IfMatch(r).Matches("9f86d081884c7d65"),
|
||||
"entity-tags split across header lines must be read as one list")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,10 @@ message BundleParameters {
|
||||
int64 bundle_for_time = 2;
|
||||
int32 log_file_count = 3;
|
||||
bool anonymize = 4;
|
||||
// anonymize_level selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict".
|
||||
string anonymize_level = 5;
|
||||
}
|
||||
|
||||
message BundleResult {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/quic-go/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
@@ -80,28 +79,6 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
|
||||
// reason a relay connection closed, so the path MTU settled on and teardown
|
||||
// cause are visible in logs. Lines carry the relay address as a structured
|
||||
// field, matching the rest of the relay client logging.
|
||||
func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
|
||||
relayLog := log.WithField("relay", addr)
|
||||
return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
|
||||
return &logging.ConnectionTracer{
|
||||
UpdatedMTU: func(mtu logging.ByteCount, done bool) {
|
||||
if done {
|
||||
relayLog.Infof("QUIC path MTU settled at %d", mtu)
|
||||
return
|
||||
}
|
||||
relayLog.Debugf("QUIC path MTU probing at %d", mtu)
|
||||
},
|
||||
ClosedConnection: func(err error) {
|
||||
relayLog.Debugf("QUIC connection closed: %v", err)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareURL(address string) (string, error) {
|
||||
var host string
|
||||
var defaultPort string
|
||||
|
||||
145
shared/relay/client/dialer/quic/quic_test.go
Normal file
145
shared/relay/client/dialer/quic/quic_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/quic-go/quic-go/qlog"
|
||||
"github.com/quic-go/quic-go/qlogwriter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
func TestCloseReason(t *testing.T) {
|
||||
transportErr := qlog.TransportErrorCode(0x2) // CONNECTION_REFUSED
|
||||
appErr := qlog.ApplicationErrorCode(42)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
event qlog.ConnectionClosed
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A close carrying nothing but an initiator still reads sensibly.
|
||||
name: "initiator only",
|
||||
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorLocal},
|
||||
want: "closed by local",
|
||||
},
|
||||
{
|
||||
name: "transport error with trigger",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorRemote,
|
||||
ConnectionError: &transportErr,
|
||||
Trigger: qlog.ConnectionCloseTriggerIdleTimeout,
|
||||
},
|
||||
want: "closed by remote, transport error: CONNECTION_REFUSED, trigger: idle_timeout",
|
||||
},
|
||||
{
|
||||
name: "application error with reason",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorLocal,
|
||||
ApplicationError: &appErr,
|
||||
Reason: "bye",
|
||||
},
|
||||
want: "closed by local, application error: 42, reason: bye",
|
||||
},
|
||||
{
|
||||
// Transport and application errors are mutually exclusive in
|
||||
// practice; if both are set the transport code wins.
|
||||
name: "transport error takes precedence over application error",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorLocal,
|
||||
ConnectionError: &transportErr,
|
||||
ApplicationError: &appErr,
|
||||
},
|
||||
want: "closed by local, transport error: CONNECTION_REFUSED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := closeReason(tt.event); got != tt.want {
|
||||
t.Errorf("closeReason() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSinkRecordEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event qlogwriter.Event
|
||||
wantLevel log.Level
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "settled MTU is logged at info",
|
||||
event: qlog.MTUUpdated{Value: 1400, Done: true},
|
||||
wantLevel: log.InfoLevel,
|
||||
wantMsg: "QUIC path MTU settled at 1400",
|
||||
},
|
||||
{
|
||||
// Probing fires repeatedly during discovery, so it stays at debug.
|
||||
name: "MTU probe is logged at debug",
|
||||
event: qlog.MTUUpdated{Value: 1300, Done: false},
|
||||
wantLevel: log.DebugLevel,
|
||||
wantMsg: "QUIC path MTU probing at 1300",
|
||||
},
|
||||
{
|
||||
name: "connection closed is logged at debug",
|
||||
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorRemote},
|
||||
wantLevel: log.DebugLevel,
|
||||
wantMsg: "QUIC connection closed: closed by remote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
recorder.RecordEvent(tt.event)
|
||||
|
||||
entries := hook.AllEntries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d log entries, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Level != tt.wantLevel {
|
||||
t.Errorf("level = %v, want %v", entries[0].Level, tt.wantLevel)
|
||||
}
|
||||
if entries[0].Message != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", entries[0].Message, tt.wantMsg)
|
||||
}
|
||||
if relay := entries[0].Data["relay"]; relay != "relay.example.com:443" {
|
||||
t.Errorf("relay field = %v, want relay.example.com:443", relay)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Events the relay client does not care about must not produce log lines.
|
||||
func TestLogSinkIgnoresUnhandledEvents(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
recorder.RecordEvent(qlog.PacketLost{})
|
||||
|
||||
if entries := hook.AllEntries(); len(entries) != 0 {
|
||||
t.Errorf("got %d log entries, want 0", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSinkSupportsSchemas(t *testing.T) {
|
||||
trace := logSink{log: log.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
if !trace.SupportsSchemas(qlog.EventSchema) {
|
||||
t.Errorf("SupportsSchemas(%q) = false, want true", qlog.EventSchema)
|
||||
}
|
||||
if trace.SupportsSchemas("urn:ietf:params:qlog:events:http3-12") {
|
||||
t.Error("SupportsSchemas() = true for an unrelated schema, want false")
|
||||
}
|
||||
if trace.AddProducer() == nil {
|
||||
t.Error("AddProducer() = nil, want a recorder")
|
||||
}
|
||||
}
|
||||
70
shared/relay/client/dialer/quic/tracer.go
Normal file
70
shared/relay/client/dialer/quic/tracer.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/quic-go/qlog"
|
||||
"github.com/quic-go/quic-go/qlogwriter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// logSink implements both qlogwriter.Trace and qlogwriter.Recorder, forwarding
|
||||
// the few qlog events the relay client cares about to logrus instead of
|
||||
// writing a qlog file. It holds no mutable state and logrus entries are safe
|
||||
// to share, so one value can serve every producer on the connection.
|
||||
type logSink struct {
|
||||
log *log.Entry
|
||||
}
|
||||
|
||||
func (s logSink) AddProducer() qlogwriter.Recorder { return s }
|
||||
|
||||
func (s logSink) SupportsSchemas(schema string) bool { return schema == qlog.EventSchema }
|
||||
|
||||
func (s logSink) RecordEvent(event qlogwriter.Event) {
|
||||
switch e := event.(type) {
|
||||
case qlog.MTUUpdated:
|
||||
if e.Done {
|
||||
s.log.Infof("QUIC path MTU settled at %d", e.Value)
|
||||
return
|
||||
}
|
||||
s.log.Debugf("QUIC path MTU probing at %d", e.Value)
|
||||
case qlog.ConnectionClosed:
|
||||
s.log.Debugf("QUIC connection closed: %s", closeReason(e))
|
||||
}
|
||||
}
|
||||
|
||||
func (s logSink) Close() error { return nil }
|
||||
|
||||
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
|
||||
// reason a relay connection closed, so the path MTU settled on and teardown
|
||||
// cause are visible in logs. Lines carry the relay address as a structured
|
||||
// field, matching the rest of the relay client logging.
|
||||
func connectionTracer(addr string) func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
|
||||
relayLog := log.WithField("relay", addr)
|
||||
return func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
|
||||
return logSink{log: relayLog}
|
||||
}
|
||||
}
|
||||
|
||||
// closeReason renders a ConnectionClosed event as a single line. The event
|
||||
// carries the error as separate initiator, code, trigger and reason fields,
|
||||
// any of which may be unset.
|
||||
func closeReason(e qlog.ConnectionClosed) string {
|
||||
parts := []string{fmt.Sprintf("closed by %s", e.Initiator)}
|
||||
switch {
|
||||
case e.ConnectionError != nil:
|
||||
parts = append(parts, fmt.Sprintf("transport error: %s", *e.ConnectionError))
|
||||
case e.ApplicationError != nil:
|
||||
parts = append(parts, fmt.Sprintf("application error: %d", *e.ApplicationError))
|
||||
}
|
||||
if e.Trigger != "" {
|
||||
parts = append(parts, fmt.Sprintf("trigger: %s", e.Trigger))
|
||||
}
|
||||
if e.Reason != "" {
|
||||
parts = append(parts, fmt.Sprintf("reason: %s", e.Reason))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user