Compare commits

..

4 Commits

20 changed files with 299 additions and 446 deletions

View File

@@ -37,16 +37,3 @@ jobs:
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'
trigger_dashboard_bump:
runs-on: ubuntu-latest
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger dashboard wasm client bump
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/dashboard
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'

View File

@@ -91,13 +91,6 @@ type Options struct {
// when the embedded client must never act as a stepping stone into
// the host's local network (e.g. the proxy's overlay peer).
BlockLANAccess bool
// LazyConnectionEnabled is a tri-state local override for lazy connections,
// mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
// flag; a set value overrides it in both directions. A short-lived client
// that reaches only a few known peers can set this to false, so its peers
// connect eagerly and the first request does not wait for the connection to
// be established.
LazyConnectionEnabled *bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -227,15 +220,6 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
if opts.LazyConnectionEnabled != nil {
// Runtime-only override, read back through lazyconn.ParseState; a set value
// wins over the management feature flag in both directions.
config.LazyConnection = "off"
if *opts.LazyConnectionEnabled {
config.LazyConnection = "on"
}
}
if opts.Performance.PreallocatedBuffersPerPool != nil {
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
}

View File

@@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() {
}
// GetInfo retrieves system information for WASM environment
func GetInfo(ctx context.Context) *Info {
func GetInfo(_ context.Context) *Info {
info := &Info{
GoOS: runtime.GOOS,
Kernel: runtime.GOARCH,
@@ -30,13 +30,6 @@ func GetInfo(ctx context.Context) *Info {
collectBrowserInfo(info)
collectLocationInfo(info)
collectSystemInfo(info)
// A caller-provided device name wins, as on the other platforms. A peer
// registered over an API keeps reporting the name it was registered with,
// so its meta does not change on the first sync.
if name := extractDeviceName(ctx, info.Hostname); name != "" {
info.Hostname = name
}
return info
}

View File

@@ -1,27 +0,0 @@
//go:build js
package system
import (
"context"
"testing"
)
// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the
// reported hostname, so a peer registered over an API keeps reporting the name
// it was registered with instead of renaming itself on its first sync.
func TestGetInfoHonorsDeviceName(t *testing.T) {
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name")
if got := GetInfo(ctx).Hostname; got != "session-name" {
t.Errorf("hostname should carry the caller's device name, got %q", got)
}
}
// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of
// always setting the context value: an empty name must not blank the hostname.
func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) {
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "")
if got := GetInfo(ctx).Hostname; got == "" {
t.Error("an empty device name must not blank the hostname")
}
}

View File

@@ -1,5 +1,3 @@
//go:build windows || (linux && !android) || (darwin && !ios) || freebsd
package system
import (

View File

@@ -764,19 +764,7 @@
"message": "Sensible Informationen anonymisieren"
},
"settings.troubleshooting.anonymize.help": {
"message": "Verbirgt IP-Adressen, Domains und andere sensible Werte."
},
"settings.troubleshooting.anonymize.info": {
"message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben."
},
"settings.troubleshooting.anonymize.none": {
"message": "Keine"
},
"settings.troubleshooting.anonymize.default": {
"message": "Standard"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strikt"
"message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Systeminformationen einschließen"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
"settings.ssh.privilege.hint": {
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.oneWay": {
"message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Anonimizar información sensible"
},
"settings.troubleshooting.anonymize.help": {
"message": "Oculta direcciones IP, dominios y otros valores sensibles."
},
"settings.troubleshooting.anonymize.info": {
"message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización."
},
"settings.troubleshooting.anonymize.none": {
"message": "Ninguno"
},
"settings.troubleshooting.anonymize.default": {
"message": "Predeterminado"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Estricto"
"message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir información del sistema"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "La operación falló."
},
"settings.ssh.privilege.hint": {
"message": "Requiere {actor}. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.oneWay": {
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Anonymiser les informations sensibles"
},
"settings.troubleshooting.anonymize.help": {
"message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles."
},
"settings.troubleshooting.anonymize.info": {
"message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation."
},
"settings.troubleshooting.anonymize.none": {
"message": "Aucune"
},
"settings.troubleshooting.anonymize.default": {
"message": "Par défaut"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strict"
"message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Inclure les informations système"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"settings.ssh.privilege.hint": {
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.oneWay": {
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Vous pouvez lactiver, mais le désactiver de nouveau nécessite {actor} :"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Érzékeny információk anonimizálása"
},
"settings.troubleshooting.anonymize.help": {
"message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket."
},
"settings.troubleshooting.anonymize.info": {
"message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nincs"
},
"settings.troubleshooting.anonymize.default": {
"message": "Alapértelmezett"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Szigorú"
"message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Rendszerinformációk beillesztése"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
"settings.ssh.privilege.hint": {
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
},
"settings.ssh.privilege.oneWay": {
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Anonimizza informazioni sensibili"
},
"settings.troubleshooting.anonymize.help": {
"message": "Nasconde indirizzi IP, domini e altri valori sensibili."
},
"settings.troubleshooting.anonymize.info": {
"message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nessuna"
},
"settings.troubleshooting.anonymize.default": {
"message": "Predefinito"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Rigoroso"
"message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Includi informazioni di sistema"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
},
"settings.ssh.privilege.hint": {
"message": "Richiede {actor}. Esegua invece questo:"
},
"settings.ssh.privilege.oneWay": {
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "機密情報を匿名化"
},
"settings.troubleshooting.anonymize.help": {
"message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。"
},
"settings.troubleshooting.anonymize.info": {
"message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。"
},
"settings.troubleshooting.anonymize.none": {
"message": "なし"
},
"settings.troubleshooting.anonymize.default": {
"message": "デフォルト"
},
"settings.troubleshooting.anonymize.strict": {
"message": "厳格"
"message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。"
},
"settings.troubleshooting.systemInfo.label": {
"message": "システム情報を含める"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
},
"settings.ssh.privilege.hint": {
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.oneWay": {
"message": "無効にはできますが、再度有効にするには{actor}が必要です:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "有効にはできますが、再度無効にするには{actor}が必要です:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Anonimizar informações sensíveis"
},
"settings.troubleshooting.anonymize.help": {
"message": "Oculta endereços IP, domínios e outros valores sensíveis."
},
"settings.troubleshooting.anonymize.info": {
"message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização."
},
"settings.troubleshooting.anonymize.none": {
"message": "Nenhum"
},
"settings.troubleshooting.anonymize.default": {
"message": "Padrão"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Estrito"
"message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir informações do sistema"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "A operação falhou."
},
"settings.ssh.privilege.hint": {
"message": "Requer {actor}. Execute isto em vez disso:"
},
"settings.ssh.privilege.oneWay": {
"message": "Você pode desativar isto, mas ativar novamente requer {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Você pode ativar isto, mas desativar novamente requer {actor}:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "Анонимизировать конфиденциальную информацию"
},
"settings.troubleshooting.anonymize.help": {
"message": "Скрывает IP-адреса, домены и другие конфиденциальные значения."
},
"settings.troubleshooting.anonymize.info": {
"message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации."
},
"settings.troubleshooting.anonymize.none": {
"message": "Нет"
},
"settings.troubleshooting.anonymize.default": {
"message": "По умолчанию"
},
"settings.troubleshooting.anonymize.strict": {
"message": "Строгий"
"message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Включить сведения о системе"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
"settings.ssh.privilege.hint": {
"message": "Требуются {actor}. Выполните вместо этого:"
},
"settings.ssh.privilege.oneWay": {
"message": "Отключить можно, но чтобы включить снова, нужны {actor}:"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Включить можно, но чтобы отключить снова, нужны {actor}:"
}
}

View File

@@ -764,19 +764,7 @@
"message": "匿名化敏感信息"
},
"settings.troubleshooting.anonymize.help": {
"message": "隐藏 IP 地址、域名和其他敏感值。"
},
"settings.troubleshooting.anonymize.info": {
"message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。"
},
"settings.troubleshooting.anonymize.none": {
"message": "无"
},
"settings.troubleshooting.anonymize.default": {
"message": "默认"
},
"settings.troubleshooting.anonymize.strict": {
"message": "严格"
"message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。"
},
"settings.troubleshooting.systemInfo.label": {
"message": "包含系统信息"
@@ -1350,14 +1338,5 @@
},
"error.unknown": {
"message": "操作失败。"
},
"settings.ssh.privilege.hint": {
"message": "需要{actor}。请改为运行:"
},
"settings.ssh.privilege.oneWay": {
"message": "您可以关闭此项,但重新开启需要{actor}"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "您可以开启此项,但再次关闭需要{actor}"
}
}

View File

@@ -56,7 +56,8 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error {
// parseClientOptions extracts NetBird options from JavaScript object
func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options := netbird.Options{
LogLevel: defaultLogLevel,
DeviceName: "dashboard-client",
LogLevel: defaultLogLevel,
}
if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() {
@@ -86,41 +87,13 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options.DeviceName = deviceName.String()
}
disableIPv6, err := boolOption(jsOptions, "disableIPv6")
if err != nil {
return options, err
if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() {
options.DisableIPv6 = disableIPv6.Bool()
}
if disableIPv6 != nil {
options.DisableIPv6 = *disableIPv6
}
// The caller decides whether this client uses lazy connections; left unset it
// defers to the management feature flag. A short-lived, interactive caller
// turns it off so its sessions reach the few peers their grant covers eagerly,
// instead of the first request waiting for the connection to be established.
lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled")
if err != nil {
return options, err
}
options.LazyConnectionEnabled = lazyConnectionEnabled
return options, nil
}
// boolOption reads a boolean option, returning nil when the caller left it out.
// js.Value.Bool panics on any other type, so a wrong type is reported instead.
func boolOption(jsOptions js.Value, name string) (*bool, error) {
v := jsOptions.Get(name)
if v.IsNull() || v.IsUndefined() {
return nil, nil
}
if v.Type() != js.TypeBoolean {
return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type())
}
b := v.Bool()
return &b, nil
}
// createStartMethod creates the start method for the client
func createStartMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) any {

View File

@@ -1,64 +0,0 @@
//go:build js
package main
import (
"syscall/js"
"testing"
)
// TestParseClientOptionsBooleans covers the boolean options against the value
// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean,
// so a wrong type has to be rejected before it reaches the client.
func TestParseClientOptionsBooleans(t *testing.T) {
t.Run("unset leaves the lazy override empty", func(t *testing.T) {
options, err := parseClientOptions(js.Global().Get("Object").New())
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled != nil {
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
}
if options.DisableIPv6 {
t.Error("disableIPv6 should default to false")
}
})
t.Run("null defers to the management flag", func(t *testing.T) {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", js.Null())
options, err := parseClientOptions(jsOptions)
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled != nil {
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
}
})
t.Run("booleans are carried through", func(t *testing.T) {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", false)
jsOptions.Set("disableIPv6", true)
options, err := parseClientOptions(jsOptions)
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled {
t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled)
}
if !options.DisableIPv6 {
t.Error("disableIPv6 should be true")
}
})
t.Run("a non-boolean is rejected", func(t *testing.T) {
for _, value := range []any{"true", 1, js.Global().Get("Object").New()} {
jsOptions := js.Global().Get("Object").New()
jsOptions.Set("lazyConnectionEnabled", value)
if _, err := parseClientOptions(jsOptions); err == nil {
t.Errorf("value %v should be rejected", value)
}
}
})
}

View File

@@ -216,13 +216,52 @@ func (e *EphemeralManager) cleanup(ctx context.Context) {
for accountID, peerIDs := range peerIDsPerAccount {
log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs)
err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true)
skipped, err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true)
if err != nil {
log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err)
e.metrics.CountCleanupError()
}
if len(skipped) > 0 {
// A skipped peer was not deleted: it is still connected in the
// store, was seen too recently, or its deletion failed. None of
// that says whether a disconnect will ever be observed for it
// again, so schedule another attempt instead of dropping it, or
// it is never collected.
log.WithContext(ctx).Debugf("cleanup: requeueing %d skipped ephemeral peers for account %s: %s", len(skipped), accountID, skipped)
e.requeuePeers(ctx, accountID, skipped)
}
e.metrics.CountPeersCleaned(int64(len(peerIDs) - len(skipped)))
}
}
// requeuePeers puts peers whose deletion was skipped back on the list with a
// fresh deadline. A peer that reconnected and disconnected in the meantime is
// already listed again and keeps its existing entry.
func (e *EphemeralManager) requeuePeers(ctx context.Context, accountID string, peerIDs []string) {
e.peersLock.Lock()
defer e.peersLock.Unlock()
added := 0
for _, id := range peerIDs {
if e.isPeerOnList(id) {
continue
}
e.metrics.CountPeersCleaned(int64(len(peerIDs)))
e.addPeer(accountID, id, e.newDeadLine())
added++
}
if added == 0 {
return
}
e.metrics.AddPending(int64(added))
if e.timer == nil {
delay := e.headPeer.deadline.Sub(timeNow()) + e.cleanupWindow
if delay < 0 {
delay = 0
}
e.timer = time.AfterFunc(delay, func() {
e.cleanup(ctx)
})
}
}

View File

@@ -2,14 +2,15 @@ package manager
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"go.uber.org/mock/gomock"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/peers"
@@ -104,11 +105,11 @@ func TestNewManager(t *testing.T) {
// Expect DeletePeers to be called for ephemeral peers
peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true).
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
for _, peerID := range peerIDs {
delete(store.account.Peers, peerID)
}
return nil
return nil, nil
}).
AnyTimes()
@@ -142,11 +143,11 @@ func TestNewManagerPeerConnected(t *testing.T) {
// Expect DeletePeers to be called for ephemeral peers (except the connected one)
peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true).
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
for _, peerID := range peerIDs {
delete(store.account.Peers, peerID)
}
return nil
return nil, nil
}).
AnyTimes()
@@ -183,11 +184,11 @@ func TestNewManagerPeerDisconnected(t *testing.T) {
// Expect DeletePeers to be called for the one disconnected peer
peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true).
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
for _, peerID := range peerIDs {
delete(store.account.Peers, peerID)
}
return nil
return nil, nil
}).
AnyTimes()
@@ -240,16 +241,16 @@ func TestCleanupSchedulingBehaviorIsBatched(t *testing.T) {
// Set up expectation that DeletePeers will be called once with all peer IDs
peersManager.EXPECT().
DeletePeers(gomock.Any(), account.Id, gomock.Any(), gomock.Any(), true).
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
// Simulate the actual deletion behavior
for _, peerID := range peerIDs {
err := mockAM.DeletePeer(ctx, accountID, peerID, userID)
if err != nil {
return err
return nil, err
}
}
mockAM.BufferUpdateAccountPeers(ctx, accountID, types.UpdateReason{})
return nil
return nil, nil
}).
Times(1)
@@ -276,6 +277,94 @@ func TestCleanupSchedulingBehaviorIsBatched(t *testing.T) {
assert.Equal(t, ephemeralPeers, mockAM.GetDeletePeerCalls(), "should have deleted all peers")
}
// TestCleanupRequeuesVetoedPeers covers a peer whose deletion is vetoed (the
// store still reports it connected, or it was seen too recently): it must be
// scheduled for another attempt rather than dropped from the list, or it is
// never collected once the veto clears.
func TestCleanupRequeuesVetoedPeers(t *testing.T) {
t.Cleanup(func() {
timeNow = time.Now
})
startTime := time.Now()
timeNow = func() time.Time {
return startTime
}
mockStore := &MockStore{}
seedPeers(mockStore, 0, 1)
ctrl := gomock.NewController(t)
peersManager := peers.NewMockManager(ctrl)
// The first attempt vetoes the peer, the second deletes it.
first := peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), []string{"ephemeral_peer_0"}, gomock.Any(), true).
Return([]string{"ephemeral_peer_0"}, nil)
peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), []string{"ephemeral_peer_0"}, gomock.Any(), true).
After(first).
DoAndReturn(func(_ context.Context, _ string, peerIDs []string, _ string, _ bool) ([]string, error) {
for _, peerID := range peerIDs {
delete(mockStore.account.Peers, peerID)
}
return nil, nil
})
mgr := NewEphemeralManager(mockStore, peersManager)
mgr.loadEphemeralPeers(context.Background())
startTime = startTime.Add(ephemeral.EphemeralLifeTime + time.Second)
mgr.cleanup(context.Background())
assert.Len(t, mockStore.account.Peers, 1, "vetoed peer must not be deleted yet")
startTime = startTime.Add(ephemeral.EphemeralLifeTime + time.Second)
mgr.cleanup(context.Background())
assert.Len(t, mockStore.account.Peers, 0, "vetoed peer should be retried and deleted once the veto clears")
}
// TestCleanupRequeuesFailedDeletes covers a peer whose deletion attempt errors
// (DeletePeers reports it as skipped alongside the aggregate error): it must be
// scheduled for another attempt rather than dropped from the list, or a
// transient store failure leaks it forever.
func TestCleanupRequeuesFailedDeletes(t *testing.T) {
t.Cleanup(func() {
timeNow = time.Now
})
startTime := time.Now()
timeNow = func() time.Time {
return startTime
}
mockStore := &MockStore{}
seedPeers(mockStore, 0, 1)
ctrl := gomock.NewController(t)
peersManager := peers.NewMockManager(ctrl)
// The first attempt fails, the second deletes the peer.
first := peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), []string{"ephemeral_peer_0"}, gomock.Any(), true).
Return([]string{"ephemeral_peer_0"}, errors.New("transient store failure"))
peersManager.EXPECT().
DeletePeers(gomock.Any(), gomock.Any(), []string{"ephemeral_peer_0"}, gomock.Any(), true).
After(first).
DoAndReturn(func(_ context.Context, _ string, peerIDs []string, _ string, _ bool) ([]string, error) {
for _, peerID := range peerIDs {
delete(mockStore.account.Peers, peerID)
}
return nil, nil
})
mgr := NewEphemeralManager(mockStore, peersManager)
mgr.loadEphemeralPeers(context.Background())
startTime = startTime.Add(ephemeral.EphemeralLifeTime + time.Second)
mgr.cleanup(context.Background())
assert.Len(t, mockStore.account.Peers, 1, "peer whose deletion failed must still exist")
startTime = startTime.Add(ephemeral.EphemeralLifeTime + time.Second)
mgr.cleanup(context.Background())
assert.Len(t, mockStore.account.Peers, 0, "peer whose deletion failed should be retried and deleted")
}
func seedPeers(store *MockStore, numberOfPeers int, numberOfEphemeralPeers int) {
store.account = newAccountWithId(context.Background(), "my account", "", "", false)

View File

@@ -8,9 +8,11 @@ import (
"net"
"time"
"github.com/hashicorp/go-multierror"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
"github.com/netbirdio/netbird/management/server/account"
@@ -30,7 +32,12 @@ type Manager interface {
GetPeerAccountID(ctx context.Context, peerID string) (string, error)
GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error)
GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error)
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error
// DeletePeers removes the given peers along with their group memberships and
// policies. Every peer that was not deleted is returned in skipped, so the
// caller can retry it later: with checkConnected, a peer that is still
// connected or was seen too recently is left in place, and a peer whose
// deletion failed is skipped with the failure aggregated into err.
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) (skipped []string, err error)
SetNetworkMapController(networkMapController network_map.Controller)
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
SetAccountManager(accountManager account.Manager)
@@ -128,91 +135,153 @@ func (m *managerImpl) GetPeerWithGroups(ctx context.Context, accountID, peerID s
return p, groups, nil
}
func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
// deletePeerOutcome is the per-peer result of a DeletePeers pass.
type deletePeerOutcome int
const (
// peerMissing means the peer no longer exists, so nothing changed.
peerMissing deletePeerOutcome = iota
// peerVetoed means the peer cannot be deleted yet: it is still connected or
// was seen too recently.
peerVetoed
// peerDeleted means the peer and its objects were removed.
peerDeleted
)
func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return err
return peerIDs, err
}
dnsDomain := m.networkMapController.GetDNSDomain(settings)
var skipped []string
var merr *multierror.Error
deletedAny := false
for _, peerID := range peerIDs {
var eventsToStore []func()
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
if e, ok := status.FromError(err); ok && e.Type() == status.NotFound {
log.WithContext(ctx).Tracef("DeletePeers: peer %s not found, skipping", peerID)
return nil
}
return err
}
if checkConnected && (peer.Status.Connected || peer.Status.LastSeen.After(time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)))) {
log.WithContext(ctx).Tracef("DeletePeers: peer %s skipped (connected=%t, lastSeen=%s, threshold=%s, ephemeral=%t)",
peerID, peer.Status.Connected,
peer.Status.LastSeen.Format(time.RFC3339),
time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)).Format(time.RFC3339),
peer.Ephemeral)
return nil
}
if err := transaction.RemovePeerFromAllGroups(ctx, peerID); err != nil {
return fmt.Errorf("failed to remove peer %s from groups", peerID)
}
peerPolicyRules, err := transaction.GetPolicyRulesByResourceID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return err
}
for _, rule := range peerPolicyRules {
policy, err := transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, rule.PolicyID)
if err != nil {
return err
}
err = transaction.DeletePolicy(ctx, accountID, rule.PolicyID)
if err != nil {
return err
}
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PolicyRemoved, policy.EventMeta())
})
}
if err = transaction.DeletePeer(ctx, accountID, peerID); err != nil {
return err
}
log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID)
if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") {
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain))
})
}
return nil
})
outcome, events, err := m.deleteSinglePeer(ctx, accountID, peerID, userID, checkConnected, dnsDomain)
if err != nil {
log.WithContext(ctx).Errorf("DeletePeers: failed to delete peer %s: %v", peerID, err)
merr = multierror.Append(merr, fmt.Errorf("delete peer %s: %w", peerID, err))
skipped = append(skipped, peerID)
continue
}
if m.integratedPeerValidator != nil {
if err = m.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
}
}
for _, event := range eventsToStore {
event()
switch outcome {
case peerVetoed:
skipped = append(skipped, peerID)
case peerDeleted:
deletedAny = true
m.notifyPeerDeleted(ctx, accountID, peerID, settings.Extra, events)
case peerMissing:
}
}
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
// Skipped or missing peers changed nothing, so an update would push an
// identical map to every peer in the account.
if deletedAny {
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
}
return nil
return skipped, nberrors.FormatErrorOrNil(merr)
}
// deleteSinglePeer deletes one peer along with its group memberships and
// policies in a single transaction. With checkConnected, a peer that is still
// connected or was seen too recently is left untouched and reported as vetoed.
// The returned events must be stored by the caller once the deletion is final.
func (m *managerImpl) deleteSinglePeer(ctx context.Context, accountID, peerID, userID string, checkConnected bool, dnsDomain string) (deletePeerOutcome, []func(), error) {
outcome := peerMissing
var eventsToStore []func()
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
outcome = peerMissing
eventsToStore = nil
p, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
if e, ok := status.FromError(err); ok && e.Type() == status.NotFound {
log.WithContext(ctx).Tracef("DeletePeers: peer %s not found, skipping", peerID)
return nil
}
return err
}
if checkConnected && (p.Status.Connected || p.Status.LastSeen.After(time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)))) {
log.WithContext(ctx).Tracef("DeletePeers: peer %s skipped (connected=%t, lastSeen=%s, threshold=%s, ephemeral=%t)",
peerID, p.Status.Connected,
p.Status.LastSeen.Format(time.RFC3339),
time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)).Format(time.RFC3339),
p.Ephemeral)
outcome = peerVetoed
return nil
}
eventsToStore, err = m.deletePeerObjects(ctx, transaction, accountID, userID, dnsDomain, p)
if err != nil {
return err
}
outcome = peerDeleted
return nil
})
if err != nil {
return outcome, nil, err
}
return outcome, eventsToStore, nil
}
// deletePeerObjects removes the peer's group memberships, its policies and the
// peer itself within the given transaction, returning the activity events to
// store once the transaction commits.
func (m *managerImpl) deletePeerObjects(ctx context.Context, transaction store.Store, accountID, userID, dnsDomain string, p *peer.Peer) ([]func(), error) {
if err := transaction.RemovePeerFromAllGroups(ctx, p.ID); err != nil {
return nil, fmt.Errorf("remove peer %s from groups: %w", p.ID, err)
}
var eventsToStore []func()
peerPolicyRules, err := transaction.GetPolicyRulesByResourceID(ctx, store.LockingStrengthNone, accountID, p.ID)
if err != nil {
return nil, err
}
for _, rule := range peerPolicyRules {
policy, err := transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, rule.PolicyID)
if err != nil {
return nil, err
}
if err := transaction.DeletePolicy(ctx, accountID, rule.PolicyID); err != nil {
return nil, err
}
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, p.ID, accountID, activity.PolicyRemoved, policy.EventMeta())
})
}
if err := transaction.DeletePeer(ctx, accountID, p.ID); err != nil {
return nil, err
}
log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", p.ID)
if !(p.ProxyMeta.Embedded || p.Meta.KernelVersion == "wasm") {
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, p.ID, accountID, activity.PeerRemovedByUser, p.EventMeta(dnsDomain))
})
}
return eventsToStore, nil
}
// notifyPeerDeleted reports a completed deletion to the integrated validator and
// stores the deletion's activity events.
func (m *managerImpl) notifyPeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings, events []func()) {
if m.integratedPeerValidator != nil {
if err := m.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, extraSettings); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
}
}
for _, event := range events {
event()
}
}
func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) {

View File

@@ -61,11 +61,12 @@ func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, clus
}
// DeletePeers mocks base method.
func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeletePeers", ctx, accountID, peerIDs, userID, checkConnected)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeletePeers indicates an expected call of DeletePeers.