Merge main into ui-refactor; port MDM support to the Wails UI

Integrates main's MDM configuration-profile feature and adapts it to the
Wails UI (this branch had already replaced the Fyne UI).

Conflict resolution:
- go.mod/go.sum: take main's deps; howett.net/plist pinned to v1.0.2-... (tidy)
- client/proto/daemon.pb.go: regenerated from the merged daemon.proto
- client/internal/peer/status.go: union of ipToKey (main) + sessionExpiresAt (HEAD)
- client/server/server.go: main's intent/liveness model (connectionGoroutineRunning,
  clientRunning no longer cleared by the goroutine) + empty-PSK guard
- client/ui/client_ui.go, client/ui/profile.go: removed (dead Fyne UI)

MDM port (backend + tray):
- services/settings.go: expose MDMManagedFields plus a managedFields map keyed
  by Config field names so the settings form can gate a control without
  translating mdm.Key* names
- tray: gate Profiles / Exit Node menus on DisableProfiles / DisableNetworks via
  GetFeatures, refreshed on the config_changed system event (replaces the legacy
  2s poll); localized MDM policy-applied toast in all shipped locales
- client/proto/metadata.go: shared constants for the config_changed /
  policy_applied event markers

PreSharedKey: GetConfig now returns preSharedKeySet (bool) instead of the masked
value; the settings form provides its own placeholder and sends a new key only
when the user types one.
This commit is contained in:
Zoltan Papp
2026-06-12 15:27:23 +02:00
56 changed files with 3639 additions and 314 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(gh api *)",
"Bash(wails3 generate *)"
]
}
}

View File

@@ -26,7 +26,10 @@ type SettingsContextValue = {
guiVersion: string;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>) => Promise<void>;
// opts.preSharedKey carries a new PSK to write. Config no longer exposes the
// PSK value (only preSharedKeySet), so it rides alongside the Config fields
// here and is sent only when non-empty.
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
saveNow: () => Promise<void>;
};
@@ -104,13 +107,14 @@ const useSettingsState = () => {
);
const save = useCallback(
async (profileName: string, next: Config) => {
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
const { preSharedKey, ...rest } = next;
async (profileName: string, next: Config, preSharedKey?: string) => {
try {
await SettingsSvc.SetConfig({
...rest,
...(preSharedKey === "**********" ? {} : { preSharedKey }),
...next,
// The daemon never returns the PSK value (only preSharedKeySet),
// so send one only when the user actually typed a new key; an
// empty field means "leave unchanged", never "clear".
...(preSharedKey ? { preSharedKey } : {}),
profileName,
username,
});
@@ -163,7 +167,7 @@ const useSettingsState = () => {
);
const saveFields = useCallback(
async (partial: Partial<Config>) => {
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
if (!loaded) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
@@ -171,7 +175,7 @@ const useSettingsState = () => {
}
const next = { ...loaded.data, ...partial };
setLoaded({ profileName: loaded.profileName, data: next });
await save(loaded.profileName, next);
await save(loaded.profileName, next, opts?.preSharedKey);
},
[loaded, save],
);

View File

@@ -20,8 +20,6 @@ const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
const MTU_MIN = 576;
const MTU_MAX = 8192;
// GetConfig returns existing PSKs as this mask; revealing it would only show the asterisks.
const PSK_MASK = "**********";
export function SettingsAdvanced() {
const { t } = useTranslation();
@@ -31,8 +29,11 @@ export function SettingsAdvanced() {
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
// PSK is write-only from the UI: the daemon returns only preSharedKeySet,
// never the value. Empty means "leave unchanged"; a typed value is sent on
// save. Reset on every config reload (e.g. after a successful save).
const [psk, setPsk] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -40,9 +41,9 @@ export function SettingsAdvanced() {
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
setPsk("");
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]);
const errors = useMemo(() => {
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
@@ -70,13 +71,13 @@ export function SettingsAdvanced() {
values.interfaceName !== config.interfaceName ||
values.wireguardPort !== config.wireguardPort ||
values.mtu !== config.mtu ||
values.preSharedKey !== config.preSharedKey;
psk !== "";
const handleSave = async () => {
if (!hasChanges || saving || hasErrors) return;
setSaving(true);
try {
await saveFields(values);
await saveFields(values, psk ? { preSharedKey: psk } : undefined);
} finally {
setSaving(false);
}
@@ -127,10 +128,14 @@ export function SettingsAdvanced() {
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
showPasswordToggle={values.preSharedKey !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={values.preSharedKey}
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
showPasswordToggle={psk !== ""}
placeholder={
config.preSharedKeySet
? t("settings.advanced.psk.configured")
: "kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="
}
value={psk}
onChange={(e) => setPsk(e.target.value)}
/>
</div>
</SectionGroup>

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an."
},
"notify.mdm.policyApplied.title": {
"message": "NetBird-Einstellungen aktualisiert"
},
"notify.mdm.policyApplied.body": {
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
},
"common.cancel": {
"message": "Abbrechen"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup-Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden."
},
"settings.advanced.psk.configured": {
"message": "Ein Pre-shared Key ist gesetzt geben Sie einen neuen ein, um ihn zu ersetzen."
},
"settings.troubleshooting.section.title": {
"message": "Debug-Paket"
},

View File

@@ -223,6 +223,14 @@
"message": "The server sent an invalid session deadline. Please sign in again.",
"description": "Body explaining the server sent an invalid session deadline and the user must sign in again."
},
"notify.mdm.policyApplied.title": {
"message": "NetBird settings updated",
"description": "Title of the desktop notification shown when an MDM (IT-managed) policy changed the daemon configuration at runtime."
},
"notify.mdm.policyApplied.body": {
"message": "Your NetBird configuration was updated by your IT policy.",
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
},
"common.cancel": {
"message": "Cancel",
"description": "Generic Cancel button label, reused across dialogs. Keep short."
@@ -927,6 +935,10 @@
"message": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.",
"description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them."
},
"settings.advanced.psk.configured": {
"message": "A pre-shared key is set — enter a new one to replace it.",
"description": "Placeholder shown in the empty PSK input when a pre-shared key is already configured; the field stays blank because the daemon never returns the value."
},
"settings.troubleshooting.section.title": {
"message": "Debug bundle",
"description": "Section heading: Debug bundle."

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo."
},
"notify.mdm.policyApplied.title": {
"message": "Configuración de NetBird actualizada"
},
"notify.mdm.policyApplied.body": {
"message": "Su configuración de NetBird fue actualizada por su política de TI."
},
"common.cancel": {
"message": "Cancelar"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida."
},
"settings.advanced.psk.configured": {
"message": "Hay una clave precompartida configurada: introduzca una nueva para reemplazarla."
},
"settings.troubleshooting.section.title": {
"message": "Paquete de diagnóstico"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter."
},
"notify.mdm.policyApplied.title": {
"message": "Paramètres NetBird mis à jour"
},
"notify.mdm.policyApplied.body": {
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
},
"common.cancel": {
"message": "Annuler"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente dune clé dinstallation NetBird. Vous ne communiquerez quavec les pairs utilisant la même clé pré-partagée."
},
"settings.advanced.psk.configured": {
"message": "Une clé pré-partagée est définie — saisissez-en une nouvelle pour la remplacer."
},
"settings.troubleshooting.section.title": {
"message": "Lot de diagnostic"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra."
},
"notify.mdm.policyApplied.title": {
"message": "NetBird beállítások frissítve"
},
"notify.mdm.policyApplied.body": {
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
},
"common.cancel": {
"message": "Mégse"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják."
},
"settings.advanced.psk.configured": {
"message": "Pre-shared kulcs be van állítva új megadásával cserélhető."
},
"settings.troubleshooting.section.title": {
"message": "Hibakeresési csomag"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso."
},
"notify.mdm.policyApplied.title": {
"message": "Impostazioni NetBird aggiornate"
},
"notify.mdm.policyApplied.body": {
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
},
"common.cancel": {
"message": "Annulla"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa."
},
"settings.advanced.psk.configured": {
"message": "Una chiave pre-condivisa è impostata: ne inserisca una nuova per sostituirla."
},
"settings.troubleshooting.section.title": {
"message": "Pacchetto di debug"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "O servidor enviou um prazo de sessão inválido. Faça login novamente."
},
"notify.mdm.policyApplied.title": {
"message": "Definições do NetBird atualizadas"
},
"notify.mdm.policyApplied.body": {
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
},
"common.cancel": {
"message": "Cancelar"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada."
},
"settings.advanced.psk.configured": {
"message": "Uma chave pré-compartilhada está definida — introduza uma nova para a substituir."
},
"settings.troubleshooting.section.title": {
"message": "Pacote de depuração"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова."
},
"notify.mdm.policyApplied.title": {
"message": "Настройки NetBird обновлены"
},
"notify.mdm.policyApplied.body": {
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
},
"common.cancel": {
"message": "Отмена"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ."
},
"settings.advanced.psk.configured": {
"message": "Общий ключ установлен — введите новый, чтобы заменить его."
},
"settings.troubleshooting.section.title": {
"message": "Отладочный пакет"
},

View File

@@ -167,6 +167,12 @@
"notify.sessionDeadlineRejected.body": {
"message": "服务器发送了无效的会话截止时间。请重新登录。"
},
"notify.mdm.policyApplied.title": {
"message": "NetBird 设置已更新"
},
"notify.mdm.policyApplied.body": {
"message": "您的 NetBird 配置已根据 IT 策略更新。"
},
"common.cancel": {
"message": "取消"
},
@@ -695,6 +701,9 @@
"settings.advanced.psk.help": {
"message": "可选的 WireGuard PSK用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。"
},
"settings.advanced.psk.configured": {
"message": "已设置预共享密钥,输入新密钥即可替换。"
},
"settings.troubleshooting.section.title": {
"message": "调试包"
},

View File

@@ -5,9 +5,28 @@ package services
import (
"context"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
// mdmKeyToConfigField maps an MDM policy key (mdm.Key*) to the JSON field name
// of the matching Config field, so GetConfig can translate the daemon's key
// names to the frontend's field names in exactly one place. Mirrors the
// conflict set the daemon enforces on SetConfig/Login (mdmManagedFieldConflicts);
// keys with no settings field are absent.
var mdmKeyToConfigField = map[string]string{
mdm.KeyManagementURL: "managementUrl",
mdm.KeyPreSharedKey: "preSharedKey",
mdm.KeyWireguardPort: "wireguardPort",
mdm.KeyRosenpassEnabled: "rosenpassEnabled",
mdm.KeyRosenpassPermissive: "rosenpassPermissive",
mdm.KeyDisableClientRoutes: "disableClientRoutes",
mdm.KeyDisableServerRoutes: "disableServerRoutes",
mdm.KeyAllowServerSSH: "serverSshAllowed",
mdm.KeyDisableAutoConnect: "disableAutoConnect",
mdm.KeyBlockInbound: "blockInbound",
}
// ConfigParams selects which profile/user to read or write config for.
type ConfigParams struct {
ProfileName string `json:"profileName"`
@@ -18,33 +37,51 @@ type ConfigParams struct {
// Pointer fields mark "set" vs "unset" so the UI can omit a value to keep the
// daemon's current setting (matching SetConfigRequest's optional semantics).
type Config struct {
ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
ConfigFile string `json:"configFile"`
LogFile string `json:"logFile"`
PreSharedKey string `json:"preSharedKey"`
InterfaceName string `json:"interfaceName"`
WireguardPort int64 `json:"wireguardPort"`
MTU int64 `json:"mtu"`
DisableAutoConnect bool `json:"disableAutoConnect"`
ServerSSHAllowed bool `json:"serverSshAllowed"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableNotifications bool `json:"disableNotifications"`
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
BlockInbound bool `json:"blockInbound"`
NetworkMonitor bool `json:"networkMonitor"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
DisableDNS bool `json:"disableDns"`
DisableIPv6 bool `json:"disableIpv6"`
BlockLANAccess bool `json:"blockLanAccess"`
EnableSSHRoot bool `json:"enableSshRoot"`
EnableSSHSFTP bool `json:"enableSshSftp"`
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
DisableSSHAuth bool `json:"disableSshAuth"`
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
ConfigFile string `json:"configFile"`
LogFile string `json:"logFile"`
// PreSharedKeySet reports whether a pre-shared key is configured, without
// exposing its value (the daemon redacts the PSK). The settings form shows
// its own "configured" / "managed by MDM" placeholder when true and sends a
// new PSK only when the user actually types one — the redaction sentinel
// never crosses to the UI.
PreSharedKeySet bool `json:"preSharedKeySet"`
InterfaceName string `json:"interfaceName"`
WireguardPort int64 `json:"wireguardPort"`
MTU int64 `json:"mtu"`
DisableAutoConnect bool `json:"disableAutoConnect"`
ServerSSHAllowed bool `json:"serverSshAllowed"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableNotifications bool `json:"disableNotifications"`
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
BlockInbound bool `json:"blockInbound"`
NetworkMonitor bool `json:"networkMonitor"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
DisableDNS bool `json:"disableDns"`
DisableIPv6 bool `json:"disableIpv6"`
BlockLANAccess bool `json:"blockLanAccess"`
EnableSSHRoot bool `json:"enableSshRoot"`
EnableSSHSFTP bool `json:"enableSshSftp"`
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
DisableSSHAuth bool `json:"disableSshAuth"`
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
// MDMManagedFields is the raw list of MDM-managed policy keys exactly as
// the daemon reports them (mdm.Key* names, e.g. "managementURL",
// "preSharedKey", "splitTunnelMode"). Includes keys with no settings
// field (split-tunnel, metrics, the Disable* feature flags). The faithful
// full set; prefer ManagedFields for per-field gating.
MDMManagedFields []string `json:"mdmManagedFields"`
// ManagedFields is the MDM-managed set normalised to Config JSON field
// names (e.g. "managementUrl", "serverSshAllowed", "preSharedKey"), so the
// settings form can gate a control with managedFields[fieldName] without
// translating the daemon's mdm.Key* names. Only managed fields are present
// (value true); keys with no settings field are omitted (the Disable*
// feature flags come via GetFeatures instead).
ManagedFields map[string]bool `json:"managedFields"`
}
// SetConfigParams is a partial update — only fields with non-nil pointers
@@ -114,7 +151,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
AdminURL: resp.GetAdminURL(),
ConfigFile: resp.GetConfigFile(),
LogFile: resp.GetLogFile(),
PreSharedKey: resp.GetPreSharedKey(),
PreSharedKeySet: resp.GetPreSharedKey() != "",
InterfaceName: resp.GetInterfaceName(),
WireguardPort: resp.GetWireguardPort(),
MTU: resp.GetMtu(),
@@ -137,6 +174,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
DisableSSHAuth: resp.GetDisableSSHAuth(),
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
MDMManagedFields: resp.GetMDMManagedFields(),
ManagedFields: configManagedFields(resp.GetMDMManagedFields()),
}, nil
}
@@ -194,3 +233,17 @@ func (s *Settings) GetFeatures(ctx context.Context) (Features, error) {
DisableNetworks: resp.GetDisableNetworks(),
}, nil
}
// configManagedFields normalises the daemon's MDM-managed key list (mdm.Key*
// names) to a set keyed by Config JSON field names, so the settings form can
// look up a field's locked state directly. Returns a non-nil (possibly empty)
// map so it marshals to {} rather than null.
func configManagedFields(managed []string) map[string]bool {
out := make(map[string]bool, len(managed))
for _, k := range managed {
if field, ok := mdmKeyToConfigField[k]; ok {
out[field] = true
}
}
return out
}

View File

@@ -33,6 +33,7 @@ const (
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDMDMPolicy = "netbird-mdm-policy"
statusError = "Error"
@@ -199,6 +200,16 @@ type Tray struct {
// succession and each may kick a refresh, but the ListNetworks fetch +
// submenu rebuild + SetMenu must not run concurrently with itself.
exitNodesRebuildMu sync.Mutex
// featureMu guards the daemon feature kill switches mirrored on the
// tray. Fetched once at startup and refreshed on every config_changed
// system event (the daemon re-applies MDM policy on each engine spawn
// and signals it via that event). Folded into the Profiles and Exit
// Node menu enablement by featuresDisabled so an operator- or
// MDM-disabled surface greys out without a periodic GetFeatures poll.
featureMu sync.Mutex
disableProfiles bool
disableNetworks bool
}
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
@@ -261,6 +272,10 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// nil-deref).
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
go t.loadProfiles()
// Seed the feature kill switches so a DisableProfiles / DisableNetworks
// policy already greys out the matching menus on the first paint
// (config_changed events refresh them afterwards).
go t.refreshFeatures()
go t.runSessionExpiryTicker()
// Notification-category registration must run after the Wails
// notifications service Startup has populated wn.appName /
@@ -376,6 +391,8 @@ func (t *Tray) relayoutMenu() {
exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...)
t.exitNodesMu.Unlock()
disableProfiles, disableNetworks := t.featuresDisabled()
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
@@ -402,13 +419,13 @@ func (t *Tray) relayoutMenu() {
t.downItem.SetEnabled(connected || connecting)
}
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0)
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
}
if t.profileSubmenuItem != nil {
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
}
if daemonVersion != "" && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))

View File

@@ -6,8 +6,10 @@ import (
"fmt"
"strings"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
@@ -22,6 +24,39 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if !ok {
return
}
// config_changed: the daemon re-applied its effective config (engine
// spawn, Up, or MDM policy diff) and signals the UI to re-sync. It
// carries no UserMessage, so it must be handled before the user-facing
// message gate below. Re-fetch the feature kill switches (DisableProfiles
// / DisableNetworks) and the notifications gate so CLI- or MDM-driven
// changes reflect in the tray without a periodic poll. This replaces the
// legacy Fyne UI's 2s GetFeatures poll.
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
log.Infof("config_changed event received (source=%s); refreshing tray features", se.Metadata[proto.MetadataSourceKey])
go t.refreshFeatures()
go t.loadConfig()
// An MDM-driven config change gets a user-facing toast so the
// operator knows their IT policy was applied. The daemon also
// emits a separate "policy_applied" event carrying an English
// UserMessage, but that text has no locale context — it's
// suppressed in shouldSkipSystemEvent and the tray builds the
// localised toast here instead. Other sources (startup, up_rpc)
// stay silent, matching the daemon's empty-UserMessage intent.
// Gated by the notifications toggle like every other INFO event.
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
t.profileMu.Lock()
enabled := t.notificationsEnabled
t.profileMu.Unlock()
if enabled {
t.notify(
t.loc.T("notify.mdm.policyApplied.title"),
t.loc.T("notify.mdm.policyApplied.body"),
notifyIDMDMPolicy,
)
}
}
return
}
// Session-warning and deadline-rejected events carry no UserMessage —
// the tray builds the localised notification body locally from metadata.
// Every other event needs a non-empty UserMessage to show anything meaningful.
@@ -112,6 +147,13 @@ func titleCase(s string) string {
// partner already drove the user-facing toast, so the v6 row is
// suppressed to avoid a duplicate notification)
func shouldSkipSystemEvent(se services.SystemEvent) bool {
// The daemon's MDM "policy_applied" event carries a hardcoded English
// UserMessage. The tray shows its own localised toast on the paired
// config_changed (source=mdm) event instead, so drop this one to avoid
// a duplicate, non-localised notification.
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
return true
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return true
}

View File

@@ -0,0 +1,47 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
log "github.com/sirupsen/logrus"
)
// refreshFeatures pulls the daemon's operator-disabled UI surfaces
// (DisableProfiles / DisableNetworks) and re-applies the tray menu gating.
// Called once at startup (ApplicationStarted) and on every config_changed
// system event — the daemon re-applies its MDM policy on each engine spawn
// and emits that event, so this is the tray's signal to re-sync the kill
// switches. It replaces the legacy Fyne UI's 2s GetFeatures poll.
func (t *Tray) refreshFeatures() {
features, err := t.svc.Settings.GetFeatures(context.Background())
if err != nil {
log.Debugf("get features: %v", err)
return
}
t.featureMu.Lock()
changed := t.disableProfiles != features.DisableProfiles ||
t.disableNetworks != features.DisableNetworks
t.disableProfiles = features.DisableProfiles
t.disableNetworks = features.DisableNetworks
t.featureMu.Unlock()
// Repaint only when a flag actually flipped: relayoutMenu rebuilds the
// whole menu tree, so a no-op refresh (the common case) must not churn
// it. relayoutMenu and fillProfileSubmenu read the cached flags via
// featuresDisabled, so the new state applies regardless of which relayout
// (this one, a status push, or a profile reload) runs last.
if changed {
t.relayoutMenu()
}
}
// featuresDisabled returns the cached DisableProfiles / DisableNetworks kill
// switches under featureMu. Read by relayoutMenu, refreshMenuItemsForStatus,
// and fillProfileSubmenu to grey out the Profiles and Exit Node menus when
// the operator (or an MDM policy) disabled those surfaces server-side.
func (t *Tray) featuresDisabled() (profiles, networks bool) {
t.featureMu.Lock()
defer t.featureMu.Unlock()
return t.disableProfiles, t.disableNetworks
}

View File

@@ -96,6 +96,13 @@ func (t *Tray) fillProfileSubmenu() {
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
// When the daemon (or an MDM policy) disables profiles, the parent menu
// is greyed out by relayoutMenu/refreshMenuItemsForStatus, but Wails'
// systray does not reliably propagate a disabled parent to its children
// on every platform — so disable each row and "Manage Profiles" too,
// mirroring the legacy Fyne UI's profile.setEnabled lock.
disableProfiles, _ := t.featuresDisabled()
t.profileSubmenu.Clear()
var activeName, activeEmail string
for _, p := range profiles {
@@ -118,15 +125,18 @@ func (t *Tray) fillProfileSubmenu() {
}
t.switchProfile(name)
})
item.SetEnabled(!disableProfiles)
if active {
activeName = name
activeEmail = p.Email
}
}
t.profileSubmenu.AddSeparator()
t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")).OnClick(func(*application.Context) {
manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles"))
manageProfiles.OnClick(func(*application.Context) {
t.svc.WindowManager.OpenSettings("profiles")
})
manageProfiles.SetEnabled(!disableProfiles)
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
if t.profileSubmenuItem != nil && activeName != "" {
t.profileSubmenuItem.SetLabel(activeName)

View File

@@ -153,8 +153,9 @@ func (t *Tray) refreshMenuItemsForStatus(st services.Status, connected bool) {
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
}
disableProfiles, _ := t.featuresDisabled()
if t.profileSubmenuItem != nil {
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
}
// Refresh the Profiles submenu on every status-text transition: the
// daemon does not emit an active-profile event, so the startup race