Compare commits

...

7 Commits

Author SHA1 Message Date
Viktor Liu
e5474e199f [client] Use WinRT COM for Windows toasts (#6013)
* Use WinRT COM for Windows toasts instead of fyne's PowerShell path

* Quote autostart path and split HKCU registry into per-user component
2026-04-28 20:54:06 +02:00
Bethuel Mmbaga
db44848e2d [management] Drop netmap calculation on peer read (#6006) 2026-04-28 18:25:56 +03:00
EL OUAZIZI Walid
9417ce3b3a fix(getting-started): Infinite healthcheck loop with existing traefik (#5871) 2026-04-28 17:22:51 +02:00
Zoltan Papp
8fc4265995 [relay] evict foreign client cache on disconnect (#6015)
* [relay] evict foreign client cache on disconnect

When a foreign relay's TCP connection drops, the manager's
onServerDisconnected handler only triggered reconnect logic for the
home server; the disconnected foreign entry stayed in the relayClients
cache. Subsequent OpenConn calls reused the closed client until the
60-second cleanup tick evicted it, breaking peer connectivity through
that relay for up to a minute.

Evict the foreign entry from the cache on disconnect so the next
OpenConn dials a fresh client.

Also:
- Make the reconnect backoff cap configurable via WithMaxBackoffInterval
  ManagerOption; the previous hard-coded 60s constant forced
  TestAutoReconnect to sleep ~61s. Test now polls Ready() and finishes
  in ~2s.
- Add NB_HOME_RELAY_SERVERS env var that overrides the relay URL list
  received from management, so a peer can be pinned to a specific home
  relay (used by the netbird-conn-lab Edge 4 reproducer).

* [client] treat empty NB_HOME_RELAY_SERVERS as unset

Returning (urls=[], ok=true) when the env var contained only separators or
whitespace caused callers to wipe the mgmt-provided relay list, leaving the
peer with no relays. Treat a parsed-empty result the same as an unset env.
2026-04-28 15:04:48 +02:00
Zoltan Papp
9c50819f20 Don't mark management disconnected on transient job stream errors (#6005)
The JOB stream is a separate channel from the SYNC stream. Server-side
EOF or other transient errors on the JOB stream do not indicate that
the management connection is unhealthy — the SYNC stream remains the
authoritative state signal.

Previously, a JOB stream EOF would call notifyDisconnected and the
client would emit OnConnecting to the UI. The backoff retry would
reconnect the JOB stream, but handleJobStream never calls notifyConnected
on success, so the UI was stuck on "Connecting" until the next SYNC
event or health check.

Keep notifyDisconnected for codes.PermissionDenied since IsLoginRequired
relies on managementError to detect expired auth.
2026-04-28 15:04:41 +02:00
Bethuel Mmbaga
6f0eff3ba0 [management] Handle single-string JWT group claim from IdPs (#6014) 2026-04-28 14:48:28 +03:00
Bethuel Mmbaga
f8745723fc [management] Add Microsoft AD FS support for embedded Dex identity providers (#6008) 2026-04-28 12:42:19 +03:00
29 changed files with 367 additions and 154 deletions

View File

@@ -200,6 +200,7 @@ Pop $0
!macroend !macroend
Function .onInit Function .onInit
SetRegView 64
StrCpy $INSTDIR "${INSTALL_DIR}" StrCpy $INSTDIR "${INSTALL_DIR}"
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString" ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString"
${If} $R0 != "" ${If} $R0 != ""
@@ -214,6 +215,10 @@ ${If} $R0 != ""
${EndIf} ${EndIf}
FunctionEnd FunctionEnd
Function un.onInit
SetRegView 64
FunctionEnd
###################################################################### ######################################################################
Section -MainProgram Section -MainProgram
${INSTALL_TYPE} ${INSTALL_TYPE}
@@ -228,6 +233,7 @@ Section -MainProgram
!else !else
File /r "..\\dist\\netbird_windows_amd64\\" File /r "..\\dist\\netbird_windows_amd64\\"
!endif !endif
File "..\\client\\ui\\assets\\netbird.png"
SectionEnd SectionEnd
###################################################################### ######################################################################
@@ -247,9 +253,11 @@ WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
; Create autostart registry entry based on checkbox ; Create autostart registry entry based on checkbox
DetailPrint "Autostart enabled: $AutostartEnabled" DetailPrint "Autostart enabled: $AutostartEnabled"
${If} $AutostartEnabled == "1" ${If} $AutostartEnabled == "1"
WriteRegStr HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" "$INSTDIR\${UI_APP_EXE}.exe" WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe" DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
${Else} ${Else}
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
DetailPrint "Autostart not enabled by user" DetailPrint "Autostart not enabled by user"
${EndIf} ${EndIf}
@@ -283,6 +291,8 @@ ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
; Remove autostart registry entry ; Remove autostart registry entry
DetailPrint "Removing autostart registry entry if exists..." DetailPrint "Removing autostart registry entry if exists..."
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
; Handle data deletion based on checkbox ; Handle data deletion based on checkbox
@@ -321,6 +331,7 @@ DetailPrint "Removing registry keys..."
DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}" DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}"
DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}" DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}"
DeleteRegKey ${REG_ROOT} "${UI_REG_APP_PATH}" DeleteRegKey ${REG_ROOT} "${UI_REG_APP_PATH}"
DeleteRegKey HKCU "Software\Classes\AppUserModelId\${APP_NAME}"
DetailPrint "Removing application directory from PATH..." DetailPrint "Removing application directory from PATH..."
EnVar::SetHKLM EnVar::SetHKLM

View File

@@ -333,6 +333,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
c.statusRecorder.MarkSignalConnected() c.statusRecorder.MarkSignalConnected()
relayURLs, token := parseRelayInfo(loginResp) relayURLs, token := parseRelayInfo(loginResp)
if override, ok := peer.OverrideRelayURLs(); ok {
log.Infof("overriding relay URLs from %s: %v", peer.EnvKeyNBHomeRelayServers, override)
relayURLs = override
}
peerConfig := loginResp.GetPeerConfig() peerConfig := loginResp.GetPeerConfig()
engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath) engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath)

View File

@@ -944,7 +944,12 @@ func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
return fmt.Errorf("update relay token: %w", err) return fmt.Errorf("update relay token: %w", err)
} }
e.relayManager.UpdateServerURLs(update.Urls) urls := update.Urls
if override, ok := peer.OverrideRelayURLs(); ok {
log.Infof("overriding relay URLs from %s: %v", peer.EnvKeyNBHomeRelayServers, override)
urls = override
}
e.relayManager.UpdateServerURLs(urls)
// Just in case the agent started with an MGM server where the relay was disabled but was later enabled. // Just in case the agent started with an MGM server where the relay was disabled but was later enabled.
// We can ignore all errors because the guard will manage the reconnection retries. // We can ignore all errors because the guard will manage the reconnection retries.

View File

@@ -7,7 +7,8 @@ import (
) )
const ( const (
EnvKeyNBForceRelay = "NB_FORCE_RELAY" EnvKeyNBForceRelay = "NB_FORCE_RELAY"
EnvKeyNBHomeRelayServers = "NB_HOME_RELAY_SERVERS"
) )
func IsForceRelayed() bool { func IsForceRelayed() bool {
@@ -16,3 +17,28 @@ func IsForceRelayed() bool {
} }
return strings.EqualFold(os.Getenv(EnvKeyNBForceRelay), "true") return strings.EqualFold(os.Getenv(EnvKeyNBForceRelay), "true")
} }
// OverrideRelayURLs returns the relay server URL list set in
// NB_HOME_RELAY_SERVERS (comma-separated) and a boolean indicating whether
// the override is active. When the env var is unset, the boolean is false
// and the caller should keep the list received from the management server.
// Intended for lab/debug scenarios where a peer must pin to a specific home
// relay regardless of what management offers.
func OverrideRelayURLs() ([]string, bool) {
raw := os.Getenv(EnvKeyNBHomeRelayServers)
if raw == "" {
return nil, false
}
parts := strings.Split(raw, ",")
urls := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
urls = append(urls, p)
}
}
if len(urls) == 0 {
return nil, false
}
return urls, true
}

View File

@@ -18,10 +18,17 @@
<Component Id="NetbirdFiles" Guid="db3165de-cc6e-4922-8396-9d892950e23e" Bitness="always64"> <Component Id="NetbirdFiles" Guid="db3165de-cc6e-4922-8396-9d892950e23e" Bitness="always64">
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird.exe" KeyPath="yes" /> <File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird.exe" KeyPath="yes" />
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird-ui.exe"> <File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird-ui.exe">
<Shortcut Id="NetbirdDesktopShortcut" Directory="DesktopFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon" /> <Shortcut Id="NetbirdDesktopShortcut" Directory="DesktopFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon">
<Shortcut Id="NetbirdStartMenuShortcut" Directory="StartMenuFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon" /> <ShortcutProperty Key="System.AppUserModel.ID" Value="NetBird" />
<ShortcutProperty Key="System.AppUserModel.ToastActivatorCLSID" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
</Shortcut>
<Shortcut Id="NetbirdStartMenuShortcut" Directory="StartMenuFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon">
<ShortcutProperty Key="System.AppUserModel.ID" Value="NetBird" />
<ShortcutProperty Key="System.AppUserModel.ToastActivatorCLSID" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
</Shortcut>
</File> </File>
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" /> <File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
<?if $(var.ArchSuffix) = "amd64" ?> <?if $(var.ArchSuffix) = "amd64" ?>
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" /> <File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
<?endif ?> <?endif ?>
@@ -46,8 +53,19 @@
</Directory> </Directory>
</StandardDirectory> </StandardDirectory>
<!-- Per-user component: HKCU keypath (auto GUID via "*"), separate from
the per-machine NetbirdFiles component to satisfy ICE57. -->
<StandardDirectory Id="ProgramMenuFolder">
<Component Id="NetbirdAumidRegistry" Guid="*">
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
</RegistryKey>
</Component>
</StandardDirectory>
<ComponentGroup Id="NetbirdFilesComponent"> <ComponentGroup Id="NetbirdFilesComponent">
<ComponentRef Id="NetbirdFiles" /> <ComponentRef Id="NetbirdFiles" />
<ComponentRef Id="NetbirdAumidRegistry" />
</ComponentGroup> </ComponentGroup>
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" /> <util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />

View File

@@ -42,6 +42,7 @@ import (
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop" "github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event" "github.com/netbirdio/netbird/client/ui/event"
"github.com/netbirdio/netbird/client/ui/notifier"
"github.com/netbirdio/netbird/client/ui/process" "github.com/netbirdio/netbird/client/ui/process"
"github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util"
@@ -260,6 +261,7 @@ type serviceClient struct {
// application with main windows. // application with main windows.
app fyne.App app fyne.App
notifier notifier.Notifier
wSettings fyne.Window wSettings fyne.Window
showAdvancedSettings bool showAdvancedSettings bool
sendNotification bool sendNotification bool
@@ -364,6 +366,7 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
cancel: cancel, cancel: cancel,
addr: args.addr, addr: args.addr,
app: args.app, app: args.app,
notifier: notifier.New(args.app),
logFile: args.logFile, logFile: args.logFile,
sendNotification: false, sendNotification: false,
@@ -892,7 +895,7 @@ func (s *serviceClient) updateStatus() error {
if err != nil { if err != nil {
log.Errorf("get service status: %v", err) log.Errorf("get service status: %v", err)
if s.connected { if s.connected {
s.app.SendNotification(fyne.NewNotification("Error", "Connection to service lost")) s.notifier.Send("Error", "Connection to service lost")
} }
s.setDisconnectedStatus() s.setDisconnectedStatus()
return err return err
@@ -1109,7 +1112,7 @@ func (s *serviceClient) onTrayReady() {
} }
}() }()
s.eventManager = event.NewManager(s.app, s.addr) s.eventManager = event.NewManager(s.notifier, s.addr)
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
s.eventManager.AddHandler(func(event *proto.SystemEvent) { s.eventManager.AddHandler(func(event *proto.SystemEvent) {
if event.Category == proto.SystemEvent_SYSTEM { if event.Category == proto.SystemEvent_SYSTEM {
@@ -1548,7 +1551,7 @@ func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) {
if enforced && s.lastNotifiedVersion != newVersion { if enforced && s.lastNotifiedVersion != newVersion {
s.lastNotifiedVersion = newVersion s.lastNotifiedVersion = newVersion
s.app.SendNotification(fyne.NewNotification("Update available", "A new version "+newVersion+" is ready to install")) s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install")
} }
} }

View File

@@ -8,7 +8,6 @@ import (
"sync" "sync"
"time" "time"
"fyne.io/fyne/v2"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -18,11 +17,17 @@ import (
"github.com/netbirdio/netbird/client/ui/desktop" "github.com/netbirdio/netbird/client/ui/desktop"
) )
// Notifier sends desktop notifications. Defined here so the event package
// does not depend on fyne or the platform-specific notifier implementation.
type Notifier interface {
Send(title, body string)
}
type Handler func(*proto.SystemEvent) type Handler func(*proto.SystemEvent)
type Manager struct { type Manager struct {
app fyne.App notifier Notifier
addr string addr string
mu sync.Mutex mu sync.Mutex
ctx context.Context ctx context.Context
@@ -31,10 +36,10 @@ type Manager struct {
handlers []Handler handlers []Handler
} }
func NewManager(app fyne.App, addr string) *Manager { func NewManager(notifier Notifier, addr string) *Manager {
return &Manager{ return &Manager{
app: app, notifier: notifier,
addr: addr, addr: addr,
} }
} }
@@ -114,7 +119,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) {
if id != "" { if id != "" {
body += fmt.Sprintf(" ID: %s", id) body += fmt.Sprintf(" ID: %s", id)
} }
e.app.SendNotification(fyne.NewNotification(title, body)) e.notifier.Send(title, body)
} }
for _, handler := range handlers { for _, handler := range handlers {

View File

@@ -9,7 +9,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"fyne.io/fyne/v2"
"fyne.io/systray" "fyne.io/systray"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -87,7 +86,7 @@ func (h *eventHandler) handleConnectClick() {
if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) { if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) {
log.Debugf("connect operation cancelled by user") log.Debugf("connect operation cancelled by user")
} else { } else {
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to connect")) h.client.notifier.Send("Error", "Failed to connect")
log.Errorf("connect failed: %v", err) log.Errorf("connect failed: %v", err)
} }
} }
@@ -112,7 +111,7 @@ func (h *eventHandler) handleDisconnectClick() {
if err := h.client.menuDownClick(); err != nil { if err := h.client.menuDownClick(); err != nil {
st, ok := status.FromError(err) st, ok := status.FromError(err)
if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) { if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) {
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to disconnect")) h.client.notifier.Send("Error", "Failed to disconnect")
log.Errorf("disconnect failed: %v", err) log.Errorf("disconnect failed: %v", err)
} else { } else {
log.Debugf("disconnect cancelled or already disconnecting") log.Debugf("disconnect cancelled or already disconnecting")
@@ -130,7 +129,7 @@ func (h *eventHandler) handleAllowSSHClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update SSH settings")) h.client.notifier.Send("Error", "Failed to update SSH settings")
} }
} }
@@ -140,7 +139,7 @@ func (h *eventHandler) handleAutoConnectClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update auto-connect settings")) h.client.notifier.Send("Error", "Failed to update auto-connect settings")
} }
} }
@@ -149,7 +148,7 @@ func (h *eventHandler) handleRosenpassClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update Rosenpass settings")) h.client.notifier.Send("Error", "Failed to update Rosenpass settings")
} }
} }
@@ -158,7 +157,7 @@ func (h *eventHandler) handleLazyConnectionClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update lazy connection settings")) h.client.notifier.Send("Error", "Failed to update lazy connection settings")
} }
} }
@@ -167,7 +166,7 @@ func (h *eventHandler) handleBlockInboundClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update block inbound settings")) h.client.notifier.Send("Error", "Failed to update block inbound settings")
} }
} }
@@ -176,7 +175,7 @@ func (h *eventHandler) handleNotificationsClick() {
if err := h.updateConfigWithErr(); err != nil { if err := h.updateConfigWithErr(); err != nil {
h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error
log.Errorf("failed to update config: %v", err) log.Errorf("failed to update config: %v", err)
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update notifications settings")) h.client.notifier.Send("Error", "Failed to update notifications settings")
} else if h.client.eventManager != nil { } else if h.client.eventManager != nil {
h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked()) h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked())
} }

View File

@@ -0,0 +1,27 @@
// Package notifier sends desktop notifications. On Windows it uses the WinRT
// COM API directly via go-toast/v2 to avoid the PowerShell window flash that
// fyne's default implementation produces. On other platforms it delegates to
// fyne.
package notifier
import "fyne.io/fyne/v2"
// Notifier sends desktop notifications.
type Notifier interface {
Send(title, body string)
}
// New returns a platform-specific Notifier. The fyne app is used as the
// fallback notifier on platforms where no native implementation is wired up,
// and on Windows when the COM path fails to initialize.
func New(app fyne.App) Notifier {
return newNotifier(app)
}
type fyneNotifier struct {
app fyne.App
}
func (f *fyneNotifier) Send(title, body string) {
f.app.SendNotification(fyne.NewNotification(title, body))
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package notifier
import "fyne.io/fyne/v2"
func newNotifier(app fyne.App) Notifier {
return &fyneNotifier{app: app}
}

View File

@@ -0,0 +1,88 @@
package notifier
import (
"os"
"path/filepath"
"sync"
"fyne.io/fyne/v2"
toast "git.sr.ht/~jackmordaunt/go-toast/v2"
"git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
log "github.com/sirupsen/logrus"
)
const (
// appID is the AppUserModelID shown in the Windows Action Center. It
// must match the System.AppUserModel.ID property set on the Start Menu
// shortcut by the MSI (see client/netbird.wxs); otherwise Windows
// groups toasts under a separate, unbranded entry.
appID = "NetBird"
// appGUID identifies the COM activation callback class. Generated once
// for NetBird; do not change without coordinating an installer bump,
// since old registry entries pointing at the previous GUID would orphan.
appGUID = "{0E1B4DE7-E148-432B-9814-544F941826EC}"
)
type comNotifier struct {
fallback *fyneNotifier
ready bool
iconPath string
}
var (
initOnce sync.Once
initErr error
)
func newNotifier(app fyne.App) Notifier {
n := &comNotifier{
fallback: &fyneNotifier{app: app},
iconPath: resolveIcon(),
}
initOnce.Do(func() {
initErr = wintoast.SetAppData(wintoast.AppData{
AppID: appID,
GUID: appGUID,
IconPath: n.iconPath,
})
})
if initErr != nil {
log.Warnf("toast: register app data failed, falling back to fyne notifications: %v", initErr)
return n.fallback
}
n.ready = true
return n
}
func (n *comNotifier) Send(title, body string) {
if !n.ready {
n.fallback.Send(title, body)
return
}
notification := toast.Notification{
AppID: appID,
Title: title,
Body: body,
Icon: n.iconPath,
}
if err := notification.Push(); err != nil {
log.Warnf("toast: push failed, using fyne fallback: %v", err)
n.fallback.Send(title, body)
}
}
// resolveIcon returns an absolute path to the toast icon, or an empty string
// when no icon can be located. Windows requires a PNG/JPG for the
// AppUserModelId IconUri registry value; .ico is silently ignored.
func resolveIcon() string {
exe, err := os.Executable()
if err != nil {
return ""
}
candidate := filepath.Join(filepath.Dir(exe), "netbird.png")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return ""
}

View File

@@ -548,7 +548,7 @@ func (p *profileMenu) refresh() {
if err != nil { if err != nil {
log.Errorf("failed to switch profile: %v", err) log.Errorf("failed to switch profile: %v", err)
// show notification dialog // show notification dialog
p.app.SendNotification(fyne.NewNotification("Error", "Failed to switch profile")) p.serviceClient.notifier.Send("Error", "Failed to switch profile")
return return
} }
@@ -628,9 +628,9 @@ func (p *profileMenu) refresh() {
} }
if err := p.eventHandler.logout(p.ctx); err != nil { if err := p.eventHandler.logout(p.ctx); err != nil {
log.Errorf("logout failed: %v", err) log.Errorf("logout failed: %v", err)
p.app.SendNotification(fyne.NewNotification("Error", "Failed to deregister")) p.serviceClient.notifier.Send("Error", "Failed to deregister")
} else { } else {
p.app.SendNotification(fyne.NewNotification("Success", "Deregistered successfully")) p.serviceClient.notifier.Send("Success", "Deregistered successfully")
} }
} }
} }

1
go.mod
View File

@@ -30,6 +30,7 @@ require (
require ( require (
fyne.io/fyne/v2 v2.7.0 fyne.io/fyne/v2 v2.7.0
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
github.com/awnumar/memguard v0.23.0 github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.38.3 github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6 github.com/aws/aws-sdk-go-v2/config v1.31.6

2
go.sum
View File

@@ -15,6 +15,8 @@ fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE= fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4= fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4=
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=

View File

@@ -193,7 +193,7 @@ func (c *Connector) ToStorageConnector() (storage.Connector, error) {
// are stored with types that Dex can open. // are stored with types that Dex can open.
func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) { func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) {
switch connType { switch connType {
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak", "adfs":
return "oidc", applyOIDCDefaults(connType, config) return "oidc", applyOIDCDefaults(connType, config)
default: default:
return connType, config return connType, config
@@ -218,6 +218,8 @@ func applyOIDCDefaults(connType string, config map[string]interface{}) map[strin
setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"}) setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"})
case "okta", "pocketid": case "okta", "pocketid":
augmented["scopes"] = []string{"openid", "profile", "email", "groups"} augmented["scopes"] = []string{"openid", "profile", "email", "groups"}
case "adfs":
augmented["scopes"] = []string{"openid", "profile", "email", "allatclaims"}
} }
return augmented return augmented

View File

@@ -168,7 +168,7 @@ func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connecto
var err error var err error
switch cfg.Type { switch cfg.Type {
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak", "adfs":
dexType = "oidc" dexType = "oidc"
configData, err = buildOIDCConnectorConfig(cfg, redirectURI) configData, err = buildOIDCConnectorConfig(cfg, redirectURI)
case "google": case "google":
@@ -220,6 +220,8 @@ func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte,
oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
case "pocketid": case "pocketid":
oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"}
case "adfs":
oidcConfig["scopes"] = []string{"openid", "profile", "email", "allatclaims"}
} }
return encodeConnectorConfig(oidcConfig) return encodeConnectorConfig(oidcConfig)
} }
@@ -283,7 +285,7 @@ func inferIdentityProviderType(dexType, connectorID string, _ map[string]interfa
// inferOIDCProviderType infers the specific OIDC provider from connector ID // inferOIDCProviderType infers the specific OIDC provider from connector ID
func inferOIDCProviderType(connectorID string) string { func inferOIDCProviderType(connectorID string) string {
connectorIDLower := strings.ToLower(connectorID) connectorIDLower := strings.ToLower(connectorID)
for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} { for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak", "adfs"} {
if strings.Contains(connectorIDLower, provider) { if strings.Contains(connectorIDLower, provider) {
return provider return provider
} }

View File

@@ -231,7 +231,20 @@ get_upstream_host() {
wait_management_proxy() { wait_management_proxy() {
local proxy_container="${1:-traefik}" local proxy_container="${1:-traefik}"
local use_docker_logs=false
set +e set +e
if [[ "$proxy_container" == "detect-traefik" ]]; then
proxy_container=$(docker ps --format "{{.ID}}\t{{.Image}}\t{{.Ports}}" \
| awk -F'\t' '$2 ~ /traefik/ && $3 ~ /:(80|443)->/ {print $1; exit}')
if [[ -z "$proxy_container" ]]; then
echo "Warning: could not auto-detect Traefik container, log output will be skipped on timeout." > /dev/stderr
else
use_docker_logs=true
fi
fi
echo -n "Waiting for NetBird server to become ready" echo -n "Waiting for NetBird server to become ready"
counter=1 counter=1
while true; do while true; do
@@ -242,7 +255,13 @@ wait_management_proxy() {
if [[ $counter -eq 60 ]]; then if [[ $counter -eq 60 ]]; then
echo "" echo ""
echo "Taking too long. Checking logs..." echo "Taking too long. Checking logs..."
$DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container" if [[ -n "$proxy_container" ]]; then
if [[ "$use_docker_logs" == "true" ]]; then
docker logs --tail=20 "$proxy_container"
else
$DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container"
fi
fi
$DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server
fi fi
echo -n " ." echo -n " ."
@@ -518,7 +537,7 @@ start_services_and_show_instructions() {
$DOCKER_COMPOSE_COMMAND up -d $DOCKER_COMPOSE_COMMAND up -d
sleep 3 sleep 3
wait_management_direct wait_management_proxy detect-traefik
echo -e "$MSG_DONE" echo -e "$MSG_DONE"
print_post_setup_instructions print_post_setup_instructions

View File

@@ -274,7 +274,7 @@ func identityProviderToConnectorConfig(idpConfig *types.IdentityProvider) *dex.C
} }
// generateIdentityProviderID generates a unique ID for an identity provider. // generateIdentityProviderID generates a unique ID for an identity provider.
// For specific provider types (okta, zitadel, entra, google, pocketid, microsoft), // For specific provider types (okta, zitadel, entra, google, pocketid, microsoft, adfs),
// the ID is prefixed with the type name. Generic OIDC providers get no prefix. // the ID is prefixed with the type name. Generic OIDC providers get no prefix.
func generateIdentityProviderID(idpType types.IdentityProviderType) string { func generateIdentityProviderID(idpType types.IdentityProviderType) string {
id := xid.New().String() id := xid.New().String()
@@ -296,6 +296,8 @@ func generateIdentityProviderID(idpType types.IdentityProviderType) string {
return "authentik-" + id return "authentik-" + id
case types.IdentityProviderTypeKeycloak: case types.IdentityProviderTypeKeycloak:
return "keycloak-" + id return "keycloak-" + id
case types.IdentityProviderTypeADFS:
return "adfs-" + id
default: default:
// Generic OIDC - no prefix // Generic OIDC - no prefix
return id return id

View File

@@ -33,8 +33,8 @@ import (
const remoteJobsMinVer = "0.64.0" const remoteJobsMinVer = "0.64.0"
// GetPeers returns a list of peers under the given account filtering out peers that do not belong to a user if // GetPeers returns peers visible to the user within an account.
// the current user is not an admin. // Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings.
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil { if err != nil {
@@ -46,14 +46,8 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return nil, status.NewPermissionValidationError(err) return nil, status.NewPermissionValidationError(err)
} }
accountPeers, err := am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter)
if err != nil {
return nil, err
}
// @note if the user has permission to read peers it shows all account peers
if allowed { if allowed {
return accountPeers, nil return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter)
} }
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
@@ -65,41 +59,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return []*nbpeer.Peer{}, nil return []*nbpeer.Peer{}, nil
} }
// @note if it does not have permission read peers then only display it's own peers return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
peers := make([]*nbpeer.Peer, 0)
peersMap := make(map[string]*nbpeer.Peer)
for _, peer := range accountPeers {
if user.Id != peer.UserID {
continue
}
peers = append(peers, peer)
peersMap[peer.ID] = peer
}
return am.getUserAccessiblePeers(ctx, accountID, peersMap, peers)
}
func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, accountID string, peersMap map[string]*nbpeer.Peer, peers []*nbpeer.Peer) ([]*nbpeer.Peer, error) {
account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return nil, err
}
approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return nil, err
}
// fetch all the peers that have access to the user's peers
for _, peer := range peers {
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, peer, approvedPeersMap, account.GetActiveGroupUsers())
for _, p := range aclPeers {
peersMap[p.ID] = p
}
}
return maps.Values(peersMap), nil
} }
// MarkPeerConnected marks peer as connected (true) or disconnected (false) // MarkPeerConnected marks peer as connected (true) or disconnected (false)
@@ -1230,7 +1190,8 @@ func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Se
return false return false
} }
// GetPeer for a given accountID, peerID and userID error if not found. // GetPeer returns a peer visible to the user within an account.
// Users with "peers:read" permission can access any peer. Otherwise, users can access only their own peer.
func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) { func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) {
peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil { if err != nil {
@@ -1255,36 +1216,6 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID,
return peer, nil return peer, nil
} }
return am.checkIfUserOwnsPeer(ctx, accountID, userID, peer)
}
func (am *DefaultAccountManager) checkIfUserOwnsPeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) {
account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return nil, err
}
approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return nil, err
}
// it is also possible that user doesn't own the peer but some of his peers have access to it,
// this is a valid case, show the peer as well.
userPeers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
if err != nil {
return nil, err
}
for _, p := range userPeers {
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, p, approvedPeersMap, account.GetActiveGroupUsers())
for _, aclPeer := range aclPeers {
if aclPeer.ID == peer.ID {
return peer, nil
}
}
}
return nil, status.Errorf(status.Internal, "user %s has no access to peer %s under account %s", userID, peer.ID, accountID) return nil, status.Errorf(status.Internal, "user %s has no access to peer %s under account %s", userID, peer.ID, accountID)
} }

View File

@@ -559,25 +559,9 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
} }
assert.NotNil(t, peer) assert.NotNil(t, peer)
// the user can see peer2 because peer1 of the user has access to peer2 due to the All group and the default rule 0 all-to-all access // the user can NOT see peer2 because it is not owned by them.
peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) // Regular users only see peers they directly own.
if err != nil { _, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser)
t.Fatal(err)
return
}
assert.NotNil(t, peer)
// delete the all-to-all policy so that user's peer1 has no access to peer2
for _, policy := range account.Policies {
err = manager.DeletePolicy(context.Background(), accountID, policy.ID, adminUser)
if err != nil {
t.Fatal(err)
return
}
}
// at this point the user can't see the details of peer2
peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) //nolint
assert.Error(t, err) assert.Error(t, err)
// admin users can always access all the peers // admin users can always access all the peers

View File

@@ -39,6 +39,8 @@ const (
IdentityProviderTypeAuthentik IdentityProviderType = "authentik" IdentityProviderTypeAuthentik IdentityProviderType = "authentik"
// IdentityProviderTypeKeycloak is the Keycloak identity provider // IdentityProviderTypeKeycloak is the Keycloak identity provider
IdentityProviderTypeKeycloak IdentityProviderType = "keycloak" IdentityProviderTypeKeycloak IdentityProviderType = "keycloak"
// IdentityProviderTypeADFS is the Microsoft AD FS identity provider
IdentityProviderTypeADFS IdentityProviderType = "adfs"
) )
// IdentityProvider represents an identity provider configuration // IdentityProvider represents an identity provider configuration
@@ -112,7 +114,8 @@ func (t IdentityProviderType) IsValid() bool {
switch t { switch t {
case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra, case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra,
IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID, IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID,
IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak: IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak,
IdentityProviderTypeADFS:
return true return true
} }
return false return false

View File

@@ -146,7 +146,11 @@ func (c *ClaimsExtractor) ToGroups(token *jwt.Token, claimName string) []string
userJWTGroups := make([]string, 0) userJWTGroups := make([]string, 0)
if claim, ok := claims[claimName]; ok { if claim, ok := claims[claimName]; ok {
if claimGroups, ok := claim.([]interface{}); ok { switch claimGroups := claim.(type) {
case string:
// Some IdPs emit a single group claim as a string instead of an array.
userJWTGroups = append(userJWTGroups, claimGroups)
case []any:
for _, g := range claimGroups { for _, g := range claimGroups {
if group, ok := g.(string); ok { if group, ok := g.(string); ok {
userJWTGroups = append(userJWTGroups, group) userJWTGroups = append(userJWTGroups, group)
@@ -154,9 +158,11 @@ func (c *ClaimsExtractor) ToGroups(token *jwt.Token, claimName string) []string
log.Debugf("JWT claim %q contains a non-string group (type: %T): %v", claimName, g, g) log.Debugf("JWT claim %q contains a non-string group (type: %T): %v", claimName, g, g)
} }
} }
default:
log.Debugf("JWT claim %q is not a string or string array (type: %T): %v", claimName, claim, claim)
} }
} else { } else {
log.Debugf("JWT claim %q is not a string array", claimName) log.Debugf("JWT claim %q is missing", claimName)
} }
return userJWTGroups return userJWTGroups

View File

@@ -249,6 +249,15 @@ func TestClaimsExtractor_ToGroups(t *testing.T) {
groupClaimName: "groups", groupClaimName: "groups",
expectedGroups: []string{}, expectedGroups: []string{},
}, },
{
name: "extracts single group string from claim",
claims: jwt.MapClaims{
"sub": "user-123",
"groups": "admin",
},
groupClaimName: "groups",
expectedGroups: []string{"admin"},
},
{ {
name: "handles custom claim name", name: "handles custom claim name",
claims: jwt.MapClaims{ claims: jwt.MapClaims{

View File

@@ -252,21 +252,19 @@ func (c *GrpcClient) handleJobStream(
c.notifyDisconnected(err) c.notifyDisconnected(err)
return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
case codes.Canceled: case codes.Canceled:
log.Debugf("management connection context has been canceled, this usually indicates shutdown") log.Debugf("job stream context has been canceled, this usually indicates shutdown")
return err return err
case codes.Unimplemented: case codes.Unimplemented:
log.Warn("Job feature is not supported by the current management server version. " + log.Warn("Job feature is not supported by the current management server version. " +
"Please update the management service to use this feature.") "Please update the management service to use this feature.")
return nil return nil
default: default:
c.notifyDisconnected(err) log.Warnf("job stream disconnected, will retry silently. Reason: %v", err)
log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
return err return err
} }
} else { } else {
// non-gRPC error // non-gRPC error
c.notifyDisconnected(err) log.Warnf("job stream disconnected, will retry silently. Reason: %v", err)
log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err)
return err return err
} }
} }

View File

@@ -2917,6 +2917,7 @@ components:
- okta - okta
- pocketid - pocketid
- microsoft - microsoft
- adfs
example: oidc example: oidc
IdentityProvider: IdentityProvider:
type: object type: object

View File

@@ -518,6 +518,7 @@ const (
IdentityProviderTypeOkta IdentityProviderType = "okta" IdentityProviderTypeOkta IdentityProviderType = "okta"
IdentityProviderTypePocketid IdentityProviderType = "pocketid" IdentityProviderTypePocketid IdentityProviderType = "pocketid"
IdentityProviderTypeZitadel IdentityProviderType = "zitadel" IdentityProviderTypeZitadel IdentityProviderType = "zitadel"
IdentityProviderTypeAdfs IdentityProviderType = "adfs"
) )
// Valid indicates whether the value is a known member of the IdentityProviderType enum. // Valid indicates whether the value is a known member of the IdentityProviderType enum.
@@ -537,6 +538,8 @@ func (e IdentityProviderType) Valid() bool {
return true return true
case IdentityProviderTypeZitadel: case IdentityProviderTypeZitadel:
return true return true
case IdentityProviderTypeAdfs:
return true
default: default:
return false return false
} }

View File

@@ -8,10 +8,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const ( const defaultMaxBackoffInterval = 60 * time.Second
// TODO: make it configurable, the manager should validate all configurable parameters
reconnectingTimeout = 60 * time.Second
)
// Guard manage the reconnection tries to the Relay server in case of disconnection event. // Guard manage the reconnection tries to the Relay server in case of disconnection event.
type Guard struct { type Guard struct {
@@ -19,14 +16,23 @@ type Guard struct {
OnNewRelayClient chan *Client OnNewRelayClient chan *Client
OnReconnected chan struct{} OnReconnected chan struct{}
serverPicker *ServerPicker serverPicker *ServerPicker
// maxBackoffInterval caps the exponential backoff between reconnect
// attempts.
maxBackoffInterval time.Duration
} }
// NewGuard creates a new guard for the relay client. // NewGuard creates a new guard for the relay client. A non-positive
func NewGuard(sp *ServerPicker) *Guard { // maxBackoffInterval falls back to defaultMaxBackoffInterval.
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
if maxBackoffInterval <= 0 {
maxBackoffInterval = defaultMaxBackoffInterval
}
g := &Guard{ g := &Guard{
OnNewRelayClient: make(chan *Client, 1), OnNewRelayClient: make(chan *Client, 1),
OnReconnected: make(chan struct{}, 1), OnReconnected: make(chan struct{}, 1),
serverPicker: sp, serverPicker: sp,
maxBackoffInterval: maxBackoffInterval,
} }
return g return g
} }
@@ -49,7 +55,7 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
} }
// start a ticker to pick a new server // start a ticker to pick a new server
ticker := exponentTicker(ctx) ticker := g.exponentTicker(ctx)
defer ticker.Stop() defer ticker.Stop()
for { for {
@@ -125,11 +131,11 @@ func (g *Guard) notifyReconnected() {
} }
} }
func exponentTicker(ctx context.Context) *backoff.Ticker { func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
bo := backoff.WithContext(&backoff.ExponentialBackOff{ bo := backoff.WithContext(&backoff.ExponentialBackOff{
InitialInterval: 2 * time.Second, InitialInterval: 2 * time.Second,
Multiplier: 2, Multiplier: 2,
MaxInterval: reconnectingTimeout, MaxInterval: g.maxBackoffInterval,
Clock: backoff.SystemClock, Clock: backoff.SystemClock,
}, ctx) }, ctx)

View File

@@ -39,6 +39,15 @@ func NewRelayTrack() *RelayTrack {
type OnServerCloseListener func() type OnServerCloseListener func()
// ManagerOption configures a Manager at construction time.
type ManagerOption func(*Manager)
// WithMaxBackoffInterval caps the exponential backoff between reconnect
// attempts to the home relay. A non-positive value keeps the default.
func WithMaxBackoffInterval(d time.Duration) ManagerOption {
return func(m *Manager) { m.maxBackoffInterval = d }
}
// Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL // Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
// and automatically reconnect to them in case disconnection. // and automatically reconnect to them in case disconnection.
// The manager also manage temporary relay connection. If a client wants to communicate with a client on a // The manager also manage temporary relay connection. If a client wants to communicate with a client on a
@@ -64,12 +73,13 @@ type Manager struct {
onReconnectedListenerFn func() onReconnectedListenerFn func()
listenerLock sync.Mutex listenerLock sync.Mutex
mtu uint16 mtu uint16
maxBackoffInterval time.Duration
} }
// NewManager creates a new manager instance. // NewManager creates a new manager instance.
// The serverURL address can be empty. In this case, the manager will not serve. // The serverURL address can be empty. In this case, the manager will not serve.
func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16) *Manager { func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager {
tokenStore := &relayAuth.TokenStore{} tokenStore := &relayAuth.TokenStore{}
m := &Manager{ m := &Manager{
@@ -86,8 +96,11 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
relayClients: make(map[string]*RelayTrack), relayClients: make(map[string]*RelayTrack),
onDisconnectedListeners: make(map[string]*list.List), onDisconnectedListeners: make(map[string]*list.List),
} }
for _, opt := range opts {
opt(m)
}
m.serverPicker.ServerURLs.Store(serverURLs) m.serverPicker.ServerURLs.Store(serverURLs)
m.reconnectGuard = NewGuard(m.serverPicker) m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval)
return m return m
} }
@@ -290,19 +303,36 @@ func (m *Manager) onServerConnected() {
go m.onReconnectedListenerFn() go m.onReconnectedListenerFn()
} }
// onServerDisconnected start to reconnection for home server only // onServerDisconnected handles relay disconnect events. For the home server it
// starts the reconnect guard. For foreign servers it evicts the now-dead client
// from the cache so the next OpenConn builds a fresh one instead of reusing a
// closed client.
func (m *Manager) onServerDisconnected(serverAddress string) { func (m *Manager) onServerDisconnected(serverAddress string) {
m.relayClientMu.Lock() m.relayClientMu.Lock()
if serverAddress == m.relayClient.connectionURL { isHome := m.relayClient != nil && serverAddress == m.relayClient.connectionURL
if isHome {
go func(client *Client) { go func(client *Client) {
m.reconnectGuard.StartReconnectTrys(m.ctx, client) m.reconnectGuard.StartReconnectTrys(m.ctx, client)
}(m.relayClient) }(m.relayClient)
} }
m.relayClientMu.Unlock() m.relayClientMu.Unlock()
if !isHome {
m.evictForeignRelay(serverAddress)
}
m.notifyOnDisconnectListeners(serverAddress) m.notifyOnDisconnectListeners(serverAddress)
} }
func (m *Manager) evictForeignRelay(serverAddress string) {
m.relayClientsMutex.Lock()
defer m.relayClientsMutex.Unlock()
if _, ok := m.relayClients[serverAddress]; ok {
delete(m.relayClients, serverAddress)
log.Debugf("evicted disconnected foreign relay client: %s", serverAddress)
}
}
func (m *Manager) listenGuardEvent(ctx context.Context) { func (m *Manager) listenGuardEvent(ctx context.Context) {
for { for {
select { select {

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"time" "time"
@@ -360,7 +361,8 @@ func TestAutoReconnect(t *testing.T) {
t.Fatalf("failed to serve manager: %s", err) t.Fatalf("failed to serve manager: %s", err)
} }
clientAlice := NewManager(mCtx, toURL(srvCfg), "alice", iface.DefaultMTU) clientAlice := NewManager(mCtx, toURL(srvCfg), "alice", iface.DefaultMTU,
WithMaxBackoffInterval(2*time.Second))
err = clientAlice.Serve() err = clientAlice.Serve()
if err != nil { if err != nil {
t.Fatalf("failed to serve manager: %s", err) t.Fatalf("failed to serve manager: %s", err)
@@ -384,7 +386,9 @@ func TestAutoReconnect(t *testing.T) {
} }
log.Infof("waiting for reconnection") log.Infof("waiting for reconnection")
time.Sleep(reconnectingTimeout + 1*time.Second) if err := waitForReady(ctx, clientAlice, 15*time.Second); err != nil {
t.Fatalf("manager did not reconnect: %s", err)
}
log.Infof("reopent the connection") log.Infof("reopent the connection")
_, err = clientAlice.OpenConn(ctx, ra, "bob") _, err = clientAlice.OpenConn(ctx, ra, "bob")
@@ -393,6 +397,21 @@ func TestAutoReconnect(t *testing.T) {
} }
} }
func waitForReady(ctx context.Context, m *Manager, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if m.Ready() {
return nil
}
select {
case <-time.After(100 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("manager not ready within %s", timeout)
}
func TestNotifierDoubleAdd(t *testing.T) { func TestNotifierDoubleAdd(t *testing.T) {
ctx := context.Background() ctx := context.Background()