mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-31 20:11:31 +02:00
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:
8
client/ui/.claude/settings.local.json
Normal file
8
client/ui/.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh api *)",
|
||||
"Bash(wails3 generate *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 d’une clé d’installation NetBird. Vous ne communiquerez qu’avec 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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Отладочный пакет"
|
||||
},
|
||||
|
||||
@@ -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": "调试包"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
47
client/ui/tray_features.go
Normal file
47
client/ui/tray_features.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user