setField("disableSshAuth", !v)}
+ onChange={(v) => sshAuth.apply(!v)}
disabled={sshAuth.disabled}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
@@ -163,41 +204,81 @@ export function SettingsSSH() {
);
}
-// PrivilegeHint explains what an unprivileged user can and cannot do with a
-// guarded control, and offers the command that does it with the privileges the
-// daemon requires. oneWay covers the control being in the guarded state already:
-// switching it back is the part that needs privileges.
-function PrivilegeHint({
+// actorLabel names the principal the daemon requires, in the user's language. The
+// Go side reports which one it is rather than wording it, because "administrator
+// privileges" is English and a translated sentence cannot borrow it.
+function actorLabel(privilege: Privilege, t: TFunction): string {
+ return privilege.actorKey === "administrator"
+ ? t("settings.ssh.privilege.actorAdministrator")
+ : t("settings.ssh.privilege.actorRoot");
+}
+
+// GuardedHint is what a control the daemon guards says to an unprivileged user.
+// There are three things worth saying, and it says at most one:
+//
+// - A prompt is open. Worth a line because it can take a few seconds to appear,
+// long enough that a control which merely went inert would read as a hang.
+// - The setting is in its guarded state already (oneWay), so the user may switch
+// it back as they please and it is switching it away again that will ask. No
+// command either way: the direction they can take is theirs to take.
+// - Only a privileged caller can move it at all, and there is no prompt to
+// raise: the command that does it belongs here, and nothing else will do.
+//
+// Which leaves the case of a control whose guarded direction is still ahead of the
+// user and a prompt that can be raised for it: nothing to say, because clicking it
+// raises the prompt and the prompt explains itself.
+function GuardedHint({
actor,
- command,
oneWay,
inverted,
+ pending,
+ command,
}: {
actor: string;
- command: string;
oneWay: boolean;
inverted: boolean;
+ pending: boolean;
+ command?: string;
}): ReactNode {
const { t } = useTranslation();
+
+ if (pending) {
+ return {t("settings.ssh.privilege.authorizePending")};
+ }
+ if (oneWay) {
+ return (
+
+
+ {inverted
+ ? t("settings.ssh.privilege.oneWayInverted", { actor })
+ : t("settings.ssh.privilege.oneWay", { actor })}
+
+
+ );
+ }
if (!command) return null;
+ return (
+
+ {t("settings.ssh.privilege.hint", { actor })}
+
+
+ {command}
+
+
+
+ );
+}
+
+// HintBox is the box a guarded control puts its explanation in, directly under the
+// control it belongs to.
+function HintBox({ children }: { children: ReactNode }): ReactNode {
return (
-
- {!oneWay
- ? t("settings.ssh.privilege.hint", { actor })
- : inverted
- ? t("settings.ssh.privilege.oneWayInverted", { actor })
- : t("settings.ssh.privilege.oneWay", { actor })}
-
-
-
- {command}
-
-
+ {children}
);
}
diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json
index 419358d36..17fb1d8ea 100644
--- a/client/ui/i18n/locales/_index.json
+++ b/client/ui/i18n/locales/_index.json
@@ -1,6 +1,7 @@
{
"languages": [
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
+ {"code": "uk", "displayName": "Українська", "englishName": "Ukrainian"},
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"},
{"code": "ru", "displayName": "Русский", "englishName": "Russian"},
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json
index d02589591..11e085927 100644
--- a/client/ui/i18n/locales/de/common.json
+++ b/client/ui/i18n/locales/de/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Alle sichtbaren Ressourcen umschalten"
},
- "settings.nav.label": {
- "message": "Einstellungsbereiche"
- },
"profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Debug-Paket fehlgeschlagen"
},
+ "settings.nav.label": {
+ "message": "Einstellungsbereiche"
+ },
"settings.tabs.general": {
"message": "Allgemein"
},
@@ -764,7 +764,19 @@
"message": "Sensible Informationen anonymisieren"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs."
+ "message": "Verbirgt IP-Adressen, Domains und andere sensible Werte."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Keine"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Standard"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Strikt"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Systeminformationen einschließen"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
+ },
+ "error.elevation_failed": {
+ "message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "root-Rechte"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "Administratorrechte"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Warten auf Autorisierung…"
}
}
diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json
index 694444497..36f00e4bd 100644
--- a/client/ui/i18n/locales/en/common.json
+++ b/client/ui/i18n/locales/en/common.json
@@ -1799,16 +1799,36 @@
"message": "Operation failed.",
"description": "Generic fallback error message used when no specific error applies."
},
+ "error.elevation_unavailable": {
+ "message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
+ "description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
+ },
+ "error.elevation_failed": {
+ "message": "The change could not be applied with elevated privileges. Run this instead:",
+ "description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "root",
+ "description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "administrator privileges",
+ "description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
+ },
"settings.ssh.privilege.hint": {
"message": "Requires {actor}. Run this instead:",
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
},
"settings.ssh.privilege.oneWay": {
- "message": "You can switch this off, but switching it back on needs {actor}:",
- "description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
+ "message": "You can switch this off, but switching it back on needs {actor}.",
+ "description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
},
"settings.ssh.privilege.oneWayInverted": {
- "message": "You can switch this on, but switching it back off needs {actor}:",
- "description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
+ "message": "You can switch this on, but switching it back off needs {actor}.",
+ "description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Waiting for authorization…",
+ "description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
}
}
diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json
index 3420b612b..41872d7a0 100644
--- a/client/ui/i18n/locales/es/common.json
+++ b/client/ui/i18n/locales/es/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Conmutar todos los recursos visibles"
},
- "settings.nav.label": {
- "message": "Secciones de configuración"
- },
"profile.switch.title": {
"message": "¿Cambiar el perfil a «{name}»?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Error en el paquete de diagnóstico"
},
+ "settings.nav.label": {
+ "message": "Secciones de configuración"
+ },
"settings.tabs.general": {
"message": "General"
},
@@ -764,7 +764,19 @@
"message": "Anonimizar información sensible"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros."
+ "message": "Oculta direcciones IP, dominios y otros valores sensibles."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Ninguno"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Predeterminado"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Estricto"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir información del sistema"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "La operación falló."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
+ },
+ "error.elevation_failed": {
+ "message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "privilegios de root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "privilegios de administrador"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Requiere {actor}. Ejecute esto en su lugar:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Esperando la autorización…"
}
}
diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json
index a83f85c12..920ef8343 100644
--- a/client/ui/i18n/locales/fr/common.json
+++ b/client/ui/i18n/locales/fr/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Activer/désactiver toutes les ressources visibles"
},
- "settings.nav.label": {
- "message": "Sections des paramètres"
- },
"profile.switch.title": {
"message": "Basculer vers le profil « {name} » ?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Échec du lot de diagnostic"
},
+ "settings.nav.label": {
+ "message": "Sections des paramètres"
+ },
"settings.tabs.general": {
"message": "Général"
},
@@ -764,7 +764,19 @@
"message": "Anonymiser les informations sensibles"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux."
+ "message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Aucune"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Par défaut"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Strict"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Inclure les informations système"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "L’opération a échoué."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
+ },
+ "error.elevation_failed": {
+ "message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "les privilèges root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "les privilèges administrateur"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Nécessite {actor}. Exécutez plutôt ceci :"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "En attente de l’autorisation…"
}
}
diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json
index b291f7a01..82996e3d3 100644
--- a/client/ui/i18n/locales/hu/common.json
+++ b/client/ui/i18n/locales/hu/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Összes látható erőforrás be/ki"
},
- "settings.nav.label": {
- "message": "Beállítások szakaszai"
- },
"profile.switch.title": {
"message": "Váltás a(z) \"{name}\" profilra?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Hibakeresési csomag sikertelen"
},
+ "settings.nav.label": {
+ "message": "Beállítások szakaszai"
+ },
"settings.tabs.general": {
"message": "Általános"
},
@@ -764,7 +764,19 @@
"message": "Érzékeny információk anonimizálása"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban."
+ "message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Nincs"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Alapértelmezett"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Szigorú"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Rendszerinformációk beillesztése"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
+ },
+ "error.elevation_unavailable": {
+ "message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
+ },
+ "error.elevation_failed": {
+ "message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "root jogosultság"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "rendszergazdai jogosultság"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Várakozás az engedélyezésre…"
}
}
diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json
index a68a8b32b..b8166aa6e 100644
--- a/client/ui/i18n/locales/it/common.json
+++ b/client/ui/i18n/locales/it/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Attiva/disattiva tutte le risorse visibili"
},
- "settings.nav.label": {
- "message": "Sezioni delle impostazioni"
- },
"profile.switch.title": {
"message": "Passare al profilo «{name}»?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Pacchetto di debug non riuscito"
},
+ "settings.nav.label": {
+ "message": "Sezioni delle impostazioni"
+ },
"settings.tabs.general": {
"message": "Generale"
},
@@ -764,7 +764,19 @@
"message": "Anonimizza informazioni sensibili"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log."
+ "message": "Nasconde indirizzi IP, domini e altri valori sensibili."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Nessuna"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Predefinito"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Rigoroso"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Includi informazioni di sistema"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
+ },
+ "error.elevation_failed": {
+ "message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "i privilegi di root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "i privilegi di amministratore"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Richiede {actor}. Esegua invece questo:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "In attesa dell'autorizzazione…"
}
}
diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json
index ec69de9a5..6ffe05e1c 100644
--- a/client/ui/i18n/locales/ja/common.json
+++ b/client/ui/i18n/locales/ja/common.json
@@ -764,7 +764,19 @@
"message": "機密情報を匿名化"
},
"settings.troubleshooting.anonymize.help": {
- "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。"
+ "message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。"
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。"
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "なし"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "デフォルト"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "厳格"
},
"settings.troubleshooting.systemInfo.label": {
"message": "システム情報を含める"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
+ },
+ "error.elevation_failed": {
+ "message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "root 権限"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "管理者権限"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "無効にはできますが、再度有効にするには{actor}が必要です。"
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "有効にはできますが、再度無効にするには{actor}が必要です。"
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "承認を待っています…"
}
}
diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json
index ef1bfd372..123e7a042 100644
--- a/client/ui/i18n/locales/pt/common.json
+++ b/client/ui/i18n/locales/pt/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Alternar todos os recursos visíveis"
},
- "settings.nav.label": {
- "message": "Seções das configurações"
- },
"profile.switch.title": {
"message": "Alternar perfil para \"{name}\"?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Falha no pacote de depuração"
},
+ "settings.nav.label": {
+ "message": "Seções das configurações"
+ },
"settings.tabs.general": {
"message": "Geral"
},
@@ -764,7 +764,19 @@
"message": "Anonimizar informações sensíveis"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs."
+ "message": "Oculta endereços IP, domínios e outros valores sensíveis."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Nenhum"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Padrão"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Estrito"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Incluir informações do sistema"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "A operação falhou."
+ },
+ "error.elevation_unavailable": {
+ "message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
+ },
+ "error.elevation_failed": {
+ "message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "privilégios de root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "privilégios de administrador"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Requer {actor}. Execute isto em vez disso:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Você pode desativar isto, mas ativar novamente requer {actor}."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Você pode ativar isto, mas desativar novamente requer {actor}."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Aguardando a autorização…"
}
}
diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json
index a876387f4..3881a3783 100644
--- a/client/ui/i18n/locales/ru/common.json
+++ b/client/ui/i18n/locales/ru/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "Переключить все видимые ресурсы"
},
- "settings.nav.label": {
- "message": "Разделы настроек"
- },
"profile.switch.title": {
"message": "Переключиться на профиль «{name}»?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "Не удалось создать отладочный пакет"
},
+ "settings.nav.label": {
+ "message": "Разделы настроек"
+ },
"settings.tabs.general": {
"message": "Общие"
},
@@ -764,7 +764,19 @@
"message": "Анонимизировать конфиденциальную информацию"
},
"settings.troubleshooting.anonymize.help": {
- "message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах."
+ "message": "Скрывает IP-адреса, домены и другие конфиденциальные значения."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Нет"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "По умолчанию"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Строгий"
},
"settings.troubleshooting.systemInfo.label": {
"message": "Включить сведения о системе"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
+ },
+ "error.elevation_failed": {
+ "message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "права root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "права администратора"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Требуются {actor}. Выполните вместо этого:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Отключить можно, но чтобы включить снова, нужны {actor}."
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Включить можно, но чтобы отключить снова, нужны {actor}."
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Ожидание авторизации…"
}
}
diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json
new file mode 100644
index 000000000..4e3f24102
--- /dev/null
+++ b/client/ui/i18n/locales/uk/common.json
@@ -0,0 +1,1376 @@
+{
+ "tray.tooltip": {
+ "message": "NetBird"
+ },
+ "tray.status.disconnected": {
+ "message": "Відключено"
+ },
+ "tray.status.daemonUnavailable": {
+ "message": "Не запущено"
+ },
+ "tray.status.error": {
+ "message": "Помилка"
+ },
+ "tray.status.connected": {
+ "message": "Підключено"
+ },
+ "tray.status.connecting": {
+ "message": "Підключення"
+ },
+ "tray.status.needsLogin": {
+ "message": "Потрібно ввійти"
+ },
+ "tray.status.loginFailed": {
+ "message": "Помилка входу"
+ },
+ "tray.status.sessionExpired": {
+ "message": "Сеанс закінчився"
+ },
+ "tray.session.expiresIn": {
+ "message": "До завершення сеансу: {remaining}"
+ },
+ "tray.session.unit.lessThanMinute": {
+ "message": "менше хвилини"
+ },
+ "tray.session.unit.minute": {
+ "message": "1 хв."
+ },
+ "tray.session.unit.minutes": {
+ "message": "{count} хв."
+ },
+ "tray.session.unit.hour": {
+ "message": "1 год."
+ },
+ "tray.session.unit.hours": {
+ "message": "{count} год."
+ },
+ "tray.session.unit.day": {
+ "message": "1 дн."
+ },
+ "tray.session.unit.days": {
+ "message": "{count} дн."
+ },
+ "tray.menu.open": {
+ "message": "Відкрити NetBird"
+ },
+ "tray.menu.connect": {
+ "message": "Підключитися"
+ },
+ "tray.menu.disconnect": {
+ "message": "Відключитися"
+ },
+ "tray.menu.exitNode": {
+ "message": "Вихідний вузол"
+ },
+ "tray.menu.networks": {
+ "message": "Ресурси"
+ },
+ "tray.menu.profiles": {
+ "message": "Профілі"
+ },
+ "tray.menu.manageProfiles": {
+ "message": "Керування профілями"
+ },
+ "tray.menu.settings": {
+ "message": "Налаштування…"
+ },
+ "tray.menu.debugBundle": {
+ "message": "Створити архів діагностики"
+ },
+ "tray.menu.about": {
+ "message": "Допомога та підтримка"
+ },
+ "tray.menu.github": {
+ "message": "GitHub"
+ },
+ "tray.menu.documentation": {
+ "message": "Документація"
+ },
+ "tray.menu.troubleshoot": {
+ "message": "Діагностика"
+ },
+ "tray.menu.downloadLatest": {
+ "message": "Завантажити останню версію"
+ },
+ "tray.menu.installVersion": {
+ "message": "Встановити версію {version}"
+ },
+ "tray.menu.guiVersion": {
+ "message": "Графічний інтерфейс: {version}"
+ },
+ "tray.menu.daemonVersion": {
+ "message": "Служба: {version}"
+ },
+ "tray.menu.versionUnknown": {
+ "message": "—"
+ },
+ "tray.menu.quit": {
+ "message": "Вийти з NetBird"
+ },
+ "notify.daemonOutdated.title": {
+ "message": "Служба NetBird застаріла"
+ },
+ "notify.daemonOutdated.body": {
+ "message": "Оновіть службу NetBird, щоб користуватися застосунком."
+ },
+ "notify.update.title": {
+ "message": "Доступне оновлення NetBird"
+ },
+ "notify.update.body": {
+ "message": "Доступна версія NetBird {version}."
+ },
+ "notify.update.enforcedSuffix": {
+ "message": " Ваш адміністратор вимагає встановити це оновлення."
+ },
+ "notify.error.title": {
+ "message": "Помилка"
+ },
+ "notify.error.connect": {
+ "message": "Не вдалося підключитися"
+ },
+ "notify.error.disconnect": {
+ "message": "Не вдалося відключитися"
+ },
+ "notify.error.switchProfile": {
+ "message": "Не вдалося перемкнутися на {profile}"
+ },
+ "notify.error.exitNode": {
+ "message": "Не вдалося оновити вихідний вузол {name}"
+ },
+ "notify.sessionExpired.title": {
+ "message": "Сеанс NetBird закінчився"
+ },
+ "notify.sessionExpired.body": {
+ "message": "Ваш сеанс NetBird закінчився. Будь ласка, увійдіть знову."
+ },
+ "notify.sessionWarning.title": {
+ "message": "Сеанс невдовзі закінчиться"
+ },
+ "notify.sessionWarning.body": {
+ "message": "Ваш сеанс NetBird закінчиться через {remaining}. Натисніть «Продовжити зараз», щоб оновити його."
+ },
+ "notify.sessionWarning.bodyGeneric": {
+ "message": "Ваш сеанс NetBird невдовзі закінчиться. Натисніть «Продовжити зараз», щоб оновити його."
+ },
+ "notify.sessionWarning.extend": {
+ "message": "Продовжити зараз"
+ },
+ "notify.sessionWarning.dismiss": {
+ "message": "Закрити"
+ },
+ "notify.sessionWarning.failed": {
+ "message": "Не вдалося продовжити сеанс NetBird"
+ },
+ "notify.sessionWarning.successTitle": {
+ "message": "Сеанс NetBird продовжено"
+ },
+ "notify.sessionWarning.successBody": {
+ "message": "Ваш сеанс успішно продовжено."
+ },
+ "notify.sessionDeadlineRejected.title": {
+ "message": "Недійсний термін дії сеансу"
+ },
+ "notify.sessionDeadlineRejected.body": {
+ "message": "Сервер надіслав недійсний термін дії сеансу. Будь ласка, увійдіть знову."
+ },
+ "notify.mdm.policyApplied.title": {
+ "message": "Налаштування NetBird оновлено"
+ },
+ "notify.mdm.policyApplied.body": {
+ "message": "Конфігурацію NetBird оновлено відповідно до політики вашої організації."
+ },
+ "common.cancel": {
+ "message": "Скасувати"
+ },
+ "common.save": {
+ "message": "Зберегти"
+ },
+ "common.saveChanges": {
+ "message": "Зберегти зміни"
+ },
+ "common.saving": {
+ "message": "Збереження…"
+ },
+ "common.close": {
+ "message": "Закрити"
+ },
+ "common.copy": {
+ "message": "Копіювати"
+ },
+ "common.togglePasswordVisibility": {
+ "message": "Показати/сховати пароль"
+ },
+ "common.increase": {
+ "message": "Збільшити"
+ },
+ "common.decrease": {
+ "message": "Зменшити"
+ },
+ "common.delete": {
+ "message": "Видалити"
+ },
+ "common.create": {
+ "message": "Створити"
+ },
+ "common.add": {
+ "message": "Додати"
+ },
+ "common.remove": {
+ "message": "Вилучити"
+ },
+ "common.refresh": {
+ "message": "Оновити"
+ },
+ "common.loading": {
+ "message": "Завантаження…"
+ },
+ "common.netbird": {
+ "message": "NetBird"
+ },
+ "common.noResults.title": {
+ "message": "Результатів не знайдено"
+ },
+ "common.noResults.description": {
+ "message": "Ми не змогли нічого знайти. Спробуйте змінити пошуковий запит або налаштування фільтрів."
+ },
+ "notConnected.title": {
+ "message": "Відключено"
+ },
+ "notConnected.description": {
+ "message": "Спочатку підключіться до NetBird, щоб переглянути детальну інформацію про піри, мережеві ресурси та вихідні вузли."
+ },
+ "connect.status.disconnected": {
+ "message": "Відключено"
+ },
+ "connect.status.connecting": {
+ "message": "Підключення…"
+ },
+ "connect.status.connected": {
+ "message": "Підключено"
+ },
+ "connect.status.disconnecting": {
+ "message": "Відключення…"
+ },
+ "connect.status.daemonUnavailable": {
+ "message": "Служба недоступна"
+ },
+ "connect.status.loginRequired": {
+ "message": "Потрібно ввійти"
+ },
+ "connect.error.loginTitle": {
+ "message": "Помилка входу"
+ },
+ "connect.error.connectTitle": {
+ "message": "Помилка підключення"
+ },
+ "connect.error.disconnectTitle": {
+ "message": "Помилка відключення"
+ },
+ "nav.peers.title": {
+ "message": "Піри"
+ },
+ "nav.peers.description": {
+ "message": "Підключено {connected} з {total}"
+ },
+ "nav.resources.title": {
+ "message": "Ресурси"
+ },
+ "nav.resources.description": {
+ "message": "Активно {active} з {total}"
+ },
+ "nav.exitNode.title": {
+ "message": "Вихідні вузли"
+ },
+ "nav.exitNode.none": {
+ "message": "Неактивний"
+ },
+ "nav.exitNode.using": {
+ "message": "Через {name}"
+ },
+ "header.openSettings": {
+ "message": "Відкрити налаштування"
+ },
+ "header.togglePanel": {
+ "message": "Показати/сховати бічну панель"
+ },
+ "profile.selector.loading": {
+ "message": "Завантаження…"
+ },
+ "profile.selector.noProfile": {
+ "message": "Немає профілю"
+ },
+ "profile.selector.searchPlaceholder": {
+ "message": "Пошук профілю за назвою…"
+ },
+ "profile.selector.emptyTitle": {
+ "message": "Профілів не знайдено"
+ },
+ "profile.selector.emptyDescription": {
+ "message": "Спробуйте змінити пошуковий запит або створіть новий профіль."
+ },
+ "profile.selector.newProfile": {
+ "message": "Новий профіль"
+ },
+ "profile.selector.moreOptions": {
+ "message": "Додаткові параметри"
+ },
+ "profile.selector.deregister": {
+ "message": "Вийти з профілю"
+ },
+ "profile.selector.delete": {
+ "message": "Видалити"
+ },
+ "profile.selector.switchTo": {
+ "message": "Перемкнутися на цей профіль"
+ },
+ "profile.selector.edit": {
+ "message": "Редагувати"
+ },
+ "profile.edit.title": {
+ "message": "Редагувати профіль"
+ },
+ "profile.edit.submit": {
+ "message": "Зберегти зміни"
+ },
+ "profile.dialog.title": {
+ "message": "Введіть назву профілю"
+ },
+ "profile.dialog.nameLabel": {
+ "message": "Назва профілю"
+ },
+ "profile.dialog.description": {
+ "message": "Вкажіть зрозумілу назву для вашого профілю."
+ },
+ "profile.dialog.placeholder": {
+ "message": "наприклад, Робота"
+ },
+ "profile.dialog.submit": {
+ "message": "Додати профіль"
+ },
+ "profile.dialog.required": {
+ "message": "Будь ласка, введіть назву профілю, наприклад, «Робота» або «Дім»."
+ },
+ "profile.dialog.managementHelp": {
+ "message": "Використовуйте NetBird Cloud або власний сервер."
+ },
+ "profile.dialog.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або додайте профіль, якщо ви впевнені, що вона правильна."
+ },
+ "header.menu.settings": {
+ "message": "Налаштування…"
+ },
+ "header.menu.defaultView": {
+ "message": "Стандартний вигляд"
+ },
+ "header.menu.advancedView": {
+ "message": "Розширений вигляд"
+ },
+ "header.menu.updateAvailable": {
+ "message": "Доступне оновлення"
+ },
+ "header.menu.open": {
+ "message": "Відкрити меню"
+ },
+ "header.profile.switch": {
+ "message": "Змінити профіль"
+ },
+ "connect.toggle.label": {
+ "message": "Перемкнути підключення NetBird"
+ },
+ "connect.localIp.label": {
+ "message": "Локальні IP-адреси"
+ },
+ "common.search": {
+ "message": "Пошук"
+ },
+ "common.filter": {
+ "message": "Фільтр"
+ },
+ "exitNodes.dropdown.trigger": {
+ "message": "Вибрати вихідний вузол"
+ },
+ "peers.row.label": {
+ "message": "Відкрити деталі для {name}, {status}"
+ },
+ "peers.dialog.title": {
+ "message": "Деталі піра"
+ },
+ "networks.row.toggle": {
+ "message": "Перемкнути {name}"
+ },
+ "networks.bulk.label": {
+ "message": "Перемкнути всі видимі ресурси"
+ },
+ "profile.switch.title": {
+ "message": "Перемкнутися на профіль «{name}»?"
+ },
+ "profile.switch.message": {
+ "message": "Ви впевнені, що хочете змінити профіль?\nВаш поточний профіль буде відключено."
+ },
+ "profile.switch.confirm": {
+ "message": "Підтвердити"
+ },
+ "profile.deregister.title": {
+ "message": "Вийти з профілю «{name}»?"
+ },
+ "profile.deregister.message": {
+ "message": "Ви впевнені, що хочете вийти з цього профілю?\nВам доведеться увійти знову, щоб використовувати його."
+ },
+ "profile.deregister.confirm": {
+ "message": "Вийти"
+ },
+ "profile.delete.title": {
+ "message": "Видалити профіль «{name}»?"
+ },
+ "profile.delete.message": {
+ "message": "Ви впевнені, що хочете видалити цей профіль?\nЦю дію неможливо скасувати."
+ },
+ "profile.delete.disabledActive": {
+ "message": "Активні профілі не можна видаляти. Перемкніться на інший профіль перед видаленням цього."
+ },
+ "profile.delete.disabledDefault": {
+ "message": "Профіль за замовчуванням не можна видалити."
+ },
+ "profile.error.switchTitle": {
+ "message": "Помилка зміни профілю"
+ },
+ "profile.error.deregisterTitle": {
+ "message": "Помилка виходу з профілю"
+ },
+ "profile.error.deleteTitle": {
+ "message": "Помилка видалення профілю"
+ },
+ "profile.error.createTitle": {
+ "message": "Помилка створення профілю"
+ },
+ "profile.error.editTitle": {
+ "message": "Помилка редагування профілю"
+ },
+ "profile.error.loadTitle": {
+ "message": "Помилка завантаження профілів"
+ },
+ "profile.dropdown.activeProfile": {
+ "message": "Активний профіль"
+ },
+ "profile.dropdown.switchProfile": {
+ "message": "Змінити профіль"
+ },
+ "profile.dropdown.noEmail": {
+ "message": "Інше"
+ },
+ "profile.dropdown.addProfile": {
+ "message": "Додати профіль"
+ },
+ "profile.dropdown.manageProfiles": {
+ "message": "Керування профілями"
+ },
+ "profile.dropdown.settings": {
+ "message": "Налаштування"
+ },
+ "settings.profiles.section.profiles": {
+ "message": "Профілі"
+ },
+ "settings.profiles.intro": {
+ "message": "Використовуйте кілька профілів NetBird одночасно, наприклад, робочий та особистий облікові записи або різні сервери керування. Додавайте профілі, виходьте з них або видаляйте їх нижче."
+ },
+ "settings.profiles.addProfile": {
+ "message": "Додати профіль"
+ },
+ "settings.profiles.active": {
+ "message": "Активний"
+ },
+ "settings.profiles.emptyTitle": {
+ "message": "Немає профілів"
+ },
+ "settings.profiles.emptyDescription": {
+ "message": "Створіть профіль, щоб підключитися до сервера керування NetBird."
+ },
+ "settings.error.loadTitle": {
+ "message": "Помилка завантаження налаштувань"
+ },
+ "settings.error.saveTitle": {
+ "message": "Помилка збереження налаштувань"
+ },
+ "settings.error.debugBundleTitle": {
+ "message": "Помилка створення архіву діагностики"
+ },
+ "settings.nav.label": {
+ "message": "Розділи налаштувань"
+ },
+ "settings.tabs.general": {
+ "message": "Загальні"
+ },
+ "settings.tabs.network": {
+ "message": "Мережа"
+ },
+ "settings.tabs.security": {
+ "message": "Безпека"
+ },
+ "settings.tabs.profiles": {
+ "message": "Профілі"
+ },
+ "settings.tabs.ssh": {
+ "message": "SSH"
+ },
+ "settings.tabs.advanced": {
+ "message": "Розширені"
+ },
+ "settings.tabs.troubleshooting": {
+ "message": "Діагностика"
+ },
+ "settings.tabs.about": {
+ "message": "Про програму"
+ },
+ "settings.tabs.updateAvailable": {
+ "message": "Доступне оновлення"
+ },
+ "settings.general.section.general": {
+ "message": "Загальні"
+ },
+ "settings.general.section.connection": {
+ "message": "Підключення"
+ },
+ "settings.general.connectOnStartup.label": {
+ "message": "Підключитися під час запуску"
+ },
+ "settings.general.connectOnStartup.help": {
+ "message": "Автоматично встановлювати підключення під час запуску служби."
+ },
+ "settings.general.notifications.label": {
+ "message": "Сповіщення на робочому столі"
+ },
+ "settings.general.notifications.help": {
+ "message": "Показувати сповіщення на робочому столі про нові оновлення та події підключення."
+ },
+ "settings.general.autostart.label": {
+ "message": "Запускати інтерфейс NetBird під час входу"
+ },
+ "settings.general.autostart.help": {
+ "message": "Автоматично запускати інтерфейс NetBird під час входу в систему. Це стосується лише графічного інтерфейсу, а не фонової служби."
+ },
+ "settings.general.autostart.errorTitle": {
+ "message": "Помилка зміни автозапуску"
+ },
+ "settings.general.keepConnectedOnQuit.label": {
+ "message": "Залишатися підключеним після виходу"
+ },
+ "settings.general.keepConnectedOnQuit.help": {
+ "message": "Підключення залишатиметься активним у фоновому режимі після закриття NetBird. Воно буде розірвано лише тоді, коли ви відключите його самостійно."
+ },
+ "settings.general.language.label": {
+ "message": "Мова інтерфейсу"
+ },
+ "settings.general.language.help": {
+ "message": "Виберіть мову для інтерфейсу NetBird."
+ },
+ "settings.general.language.search": {
+ "message": "Пошук мови…"
+ },
+ "settings.general.language.empty": {
+ "message": "Не знайдено жодної мови."
+ },
+ "settings.general.management.label": {
+ "message": "Сервер керування"
+ },
+ "settings.general.management.help": {
+ "message": "Підключайтеся до NetBird Cloud або власного сервера керування. Зміни призведуть до перепідключення клієнта."
+ },
+ "settings.general.management.cloud": {
+ "message": "Cloud"
+ },
+ "settings.general.management.selfHosted": {
+ "message": "Власний сервер"
+ },
+ "settings.general.management.urlPlaceholder": {
+ "message": "https://netbird.selfhosted.com:443"
+ },
+ "settings.general.management.urlError": {
+ "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+ },
+ "settings.general.management.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або все одно збережіть зміни, якщо ви впевнені, що вона правильна."
+ },
+ "settings.general.management.switchCloudTitle": {
+ "message": "Перемкнутися на NetBird Cloud?"
+ },
+ "settings.general.management.switchCloudMessage": {
+ "message": "Це відключить вас від власного сервера.\nВам може знадобитися увійти знову."
+ },
+ "settings.general.management.switchCloudConfirm": {
+ "message": "Перемкнутися на Cloud"
+ },
+ "settings.network.section.connectivity": {
+ "message": "Підключення"
+ },
+ "settings.network.section.routingDns": {
+ "message": "Маршрутизація та DNS"
+ },
+ "settings.network.monitor.label": {
+ "message": "Перепідключатися при зміні мережі"
+ },
+ "settings.network.monitor.help": {
+ "message": "Відстежувати мережу й автоматично перепідключатися у разі таких змін, як перемикання Wi-Fi, зміна Ethernet-підключення або вихід із режиму сну."
+ },
+ "settings.network.dns.label": {
+ "message": "Увімкнути DNS"
+ },
+ "settings.network.dns.help": {
+ "message": "Застосовувати налаштування DNS, якими керує NetBird, до локального DNS-розв’язувача хоста."
+ },
+ "settings.network.clientRoutes.label": {
+ "message": "Увімкнути клієнтські маршрути"
+ },
+ "settings.network.clientRoutes.help": {
+ "message": "Приймати маршрути від інших пірів для доступу до їхніх мереж."
+ },
+ "settings.network.serverRoutes.label": {
+ "message": "Увімкнути серверні маршрути"
+ },
+ "settings.network.serverRoutes.help": {
+ "message": "Анонсувати локальні маршрути цього хоста іншим пірам."
+ },
+ "settings.network.ipv6.label": {
+ "message": "Увімкнути IPv6"
+ },
+ "settings.network.ipv6.help": {
+ "message": "Використовувати адресацію IPv6 для оверлейної мережі NetBird."
+ },
+ "settings.security.section.firewall": {
+ "message": "Брандмауер"
+ },
+ "settings.security.section.encryption": {
+ "message": "Шифрування"
+ },
+ "settings.security.blockInbound.label": {
+ "message": "Блокувати вхідний трафік"
+ },
+ "settings.security.blockInbound.help": {
+ "message": "Відхиляти небажані підключення від пірів до цього пристрою та будь-яких мереж, які він маршрутизує. Вихідний трафік не обмежується."
+ },
+ "settings.security.blockLan.label": {
+ "message": "Блокувати доступ до LAN"
+ },
+ "settings.security.blockLan.help": {
+ "message": "Заборонити пірам отримувати доступ до вашої локальної мережі або її пристроїв, коли цей пристрій маршрутизує їхній трафік."
+ },
+ "settings.security.rosenpass.label": {
+ "message": "Увімкнути постквантову стійкість"
+ },
+ "settings.security.rosenpass.help": {
+ "message": "Додати постквантовий обмін ключами через Rosenpass поверх WireGuard®."
+ },
+ "settings.security.rosenpassPermissive.label": {
+ "message": "Увімкнути дозвільний режим"
+ },
+ "settings.security.rosenpassPermissive.help": {
+ "message": "Дозволити підключення до пірів без підтримки постквантової стійкості."
+ },
+ "settings.ssh.section.server": {
+ "message": "Сервер"
+ },
+ "settings.ssh.section.capabilities": {
+ "message": "Можливості"
+ },
+ "settings.ssh.section.authentication": {
+ "message": "Автентифікація"
+ },
+ "settings.ssh.server.label": {
+ "message": "Увімкнути SSH-сервер"
+ },
+ "settings.ssh.server.help": {
+ "message": "Запустити SSH-сервер NetBird на цьому хості, щоб інші піри могли підключатися до нього."
+ },
+ "settings.ssh.root.label": {
+ "message": "Дозволити вхід як root"
+ },
+ "settings.ssh.root.help": {
+ "message": "Дозволити пірам входити як користувач root. Вимкніть, щоб вимагати непривілейований обліковий запис."
+ },
+ "settings.ssh.sftp.label": {
+ "message": "Дозволити SFTP"
+ },
+ "settings.ssh.sftp.help": {
+ "message": "Безпечно передавати файли за допомогою нативних клієнтів SFTP або SCP."
+ },
+ "settings.ssh.localForward.label": {
+ "message": "Локальне переспрямування портів"
+ },
+ "settings.ssh.localForward.help": {
+ "message": "Дозволити пірам, що підключаються, переспрямовувати локальні порти до сервісів, доступних із цього хоста."
+ },
+ "settings.ssh.remoteForward.label": {
+ "message": "Віддалене переспрямування портів"
+ },
+ "settings.ssh.remoteForward.help": {
+ "message": "Дозволити підключеним пірам відкривати порти на цьому хості з переспрямуванням на свої машини."
+ },
+ "settings.ssh.jwt.label": {
+ "message": "Увімкнути JWT-автентифікацію"
+ },
+ "settings.ssh.jwt.help": {
+ "message": "Перевіряти кожен сеанс SSH через ваш IdP для ідентифікації користувачів та аудиту. Вимкніть, щоб покладатися лише на політики мережевих ACL, що корисно, коли IdP недоступний."
+ },
+ "settings.ssh.jwtTtl.label": {
+ "message": "Час кешування JWT (TTL)"
+ },
+ "settings.ssh.jwtTtl.help": {
+ "message": "Як довго цей клієнт кешує JWT перед повторним запитом для вихідних SSH-з’єднань. Встановіть 0, щоб вимкнути кешування та проходити автентифікацію при кожному підключенні."
+ },
+ "settings.ssh.jwtTtl.suffix": {
+ "message": "сек."
+ },
+ "settings.advanced.section.interface": {
+ "message": "Інтерфейс"
+ },
+ "settings.advanced.section.security": {
+ "message": "Безпека"
+ },
+ "settings.advanced.interfaceName.label": {
+ "message": "Назва"
+ },
+ "settings.advanced.interfaceName.error": {
+ "message": "Використовуйте 1-15 літер, цифр, крапок, дефісів або підкреслень."
+ },
+ "settings.advanced.interfaceName.errorMac": {
+ "message": "Повинно починатися з «utun», після якого має йти число (наприклад, utun100)."
+ },
+ "settings.advanced.port.label": {
+ "message": "Порт"
+ },
+ "settings.advanced.port.error": {
+ "message": "Введіть порт між {min} та {max}."
+ },
+ "settings.advanced.port.help": {
+ "message": "Якщо встановлено 0, буде використано випадковий вільний порт."
+ },
+ "settings.advanced.mtu.label": {
+ "message": "MTU"
+ },
+ "settings.advanced.mtu.error": {
+ "message": "Введіть значення MTU між {min} та {max}."
+ },
+ "settings.advanced.psk.label": {
+ "message": "Попередньо узгоджений ключ"
+ },
+ "settings.advanced.psk.help": {
+ "message": "Додатковий PSK WireGuard для симетричного шифрування. Це не те саме, що NetBird Setup Key. Ви зможете обмінюватися даними лише з тими пірами, які використовують такий самий попередньо узгоджений ключ."
+ },
+ "settings.troubleshooting.section.title": {
+ "message": "Архів діагностики"
+ },
+ "settings.troubleshooting.anonymize.label": {
+ "message": "Анонімізувати чутливу інформацію"
+ },
+ "settings.troubleshooting.anonymize.help": {
+ "message": "Приховує IP-адреси, домени та інші конфіденційні дані."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Вимкнено"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Стандартний"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Суворий"
+ },
+ "settings.troubleshooting.systemInfo.label": {
+ "message": "Додати інформацію про систему"
+ },
+ "settings.troubleshooting.systemInfo.help": {
+ "message": "Додати дані про ОС, ядро, мережеві інтерфейси та таблиці маршрутизації."
+ },
+ "settings.troubleshooting.upload.label": {
+ "message": "Завантажити архів на сервери NetBird"
+ },
+ "settings.troubleshooting.upload.help": {
+ "message": "Створює ключ завантаження, який можна передати службі підтримки NetBird."
+ },
+ "settings.troubleshooting.trace.label": {
+ "message": "Увімкнути журнали рівня TRACE"
+ },
+ "settings.troubleshooting.trace.help": {
+ "message": "Підвищує рівень журналювання до TRACE на час створення архіву та відновлює його після завершення."
+ },
+ "settings.troubleshooting.capture.label": {
+ "message": "Запис сеансу"
+ },
+ "settings.troubleshooting.capture.help": {
+ "message": "Перепідключає NetBird і чекає, щоб ви могли відтворити проблему."
+ },
+ "settings.troubleshooting.packets.label": {
+ "message": "Захоплювати мережеві пакети"
+ },
+ "settings.troubleshooting.packets.help": {
+ "message": "Зберігає файл .pcap із мережевим трафіком протягом сеансу захоплення."
+ },
+ "settings.troubleshooting.duration.label": {
+ "message": "Тривалість захоплення"
+ },
+ "settings.troubleshooting.duration.help": {
+ "message": "Скільки часу триває сеанс захоплення."
+ },
+ "settings.troubleshooting.duration.suffix": {
+ "message": "хв."
+ },
+ "settings.troubleshooting.create": {
+ "message": "Створити архів"
+ },
+ "settings.troubleshooting.progress.description": {
+ "message": "Збір журналів, даних про систему та інформації про стан підключення. Зазвичай це займає хвилину. Ви можете продовжувати використовувати NetBird або закрити вікно налаштувань, поки процес триває."
+ },
+ "settings.troubleshooting.cancelling": {
+ "message": "Скасування…"
+ },
+ "settings.troubleshooting.done.uploadedTitle": {
+ "message": "Архів діагностики успішно завантажено!"
+ },
+ "settings.troubleshooting.done.savedTitle": {
+ "message": "Архів збережено"
+ },
+ "settings.troubleshooting.done.uploadedDescription": {
+ "message": "Поділіться ключем завантаження нижче зі службою підтримки NetBird. Локальну копію також збережено на вашому пристрої."
+ },
+ "settings.troubleshooting.done.savedDescription": {
+ "message": "Ваш архів діагностики збережено локально."
+ },
+ "settings.troubleshooting.done.copyKey": {
+ "message": "Копіювати ключ"
+ },
+ "settings.troubleshooting.done.openFolder": {
+ "message": "Відкрити папку"
+ },
+ "settings.troubleshooting.done.openFileLocation": {
+ "message": "Відкрити розташування файлу"
+ },
+ "settings.troubleshooting.uploadFailedWithReason": {
+ "message": "Помилка завантаження: {reason} Архів все одно збережено локально"
+ },
+ "settings.troubleshooting.uploadFailed": {
+ "message": "Помилка завантаження. Архів все одно збережено локально."
+ },
+ "settings.troubleshooting.stage.reconnecting": {
+ "message": "Перепідключення NetBird…"
+ },
+ "settings.troubleshooting.stage.capturing": {
+ "message": "Запис журналів діагностики"
+ },
+ "settings.troubleshooting.stage.bundling": {
+ "message": "Створення архіву діагностики…"
+ },
+ "settings.troubleshooting.stage.uploading": {
+ "message": "Завантаження на сервери NetBird…"
+ },
+ "settings.troubleshooting.stage.cancelling": {
+ "message": "Скасування…"
+ },
+ "settings.about.client": {
+ "message": "NetBird Client v{version}"
+ },
+ "settings.about.clientName": {
+ "message": "NetBird Client"
+ },
+ "settings.about.development": {
+ "message": "[Розробка]"
+ },
+ "settings.about.gui": {
+ "message": "Графічний інтерфейс v{version}"
+ },
+ "settings.about.guiName": {
+ "message": "Графічний інтерфейс"
+ },
+ "settings.about.copyright": {
+ "message": "© {year} NetBird. Усі права захищено."
+ },
+ "settings.about.links.imprint": {
+ "message": "Реквізити"
+ },
+ "settings.about.links.privacy": {
+ "message": "Конфіденційність"
+ },
+ "settings.about.links.cla": {
+ "message": "CLA"
+ },
+ "settings.about.links.terms": {
+ "message": "Умови використання"
+ },
+ "settings.about.community.github": {
+ "message": "GitHub"
+ },
+ "settings.about.community.slack": {
+ "message": "Slack"
+ },
+ "settings.about.community.forum": {
+ "message": "Форум"
+ },
+ "settings.about.community.documentation": {
+ "message": "Документація"
+ },
+ "settings.about.community.feedback": {
+ "message": "Зворотний зв’язок"
+ },
+ "update.banner.message": {
+ "message": "NetBird {version} готовий до встановлення."
+ },
+ "update.banner.later": {
+ "message": "Пізніше"
+ },
+ "update.banner.installNow": {
+ "message": "Встановити зараз"
+ },
+ "update.card.versionAvailableDownload": {
+ "message": "Версія {version} доступна для завантаження."
+ },
+ "update.card.versionAvailableInstall": {
+ "message": "Версія {version} доступна для встановлення."
+ },
+ "update.card.whatsNew": {
+ "message": "Що нового?"
+ },
+ "update.card.installNow": {
+ "message": "Встановити зараз"
+ },
+ "update.card.getInstaller": {
+ "message": "Завантажити"
+ },
+ "update.card.autoCheckInterval": {
+ "message": "NetBird перевіряє наявність оновлень у фоновому режимі."
+ },
+ "update.card.changelog": {
+ "message": "Список змін"
+ },
+ "update.card.onLatestVersion": {
+ "message": "Ви використовуєте останню версію"
+ },
+ "update.header.tooltip": {
+ "message": "Доступне оновлення"
+ },
+ "update.overlay.updatingVersion": {
+ "message": "Оновлення NetBird до v{version}"
+ },
+ "update.overlay.updating": {
+ "message": "Оновлення NetBird"
+ },
+ "update.overlay.description": {
+ "message": "Доступна новіша версія, яка зараз встановлюється. NetBird автоматично перезапуститься після завершення оновлення."
+ },
+ "update.overlay.error.timeoutTitle": {
+ "message": "Оновлення триває занадто довго"
+ },
+ "update.overlay.error.timeoutDescription": {
+ "message": "Встановлення {target} тривало занадто довго і не завершилося."
+ },
+ "update.overlay.error.canceledTitle": {
+ "message": "Оновлення зупинено"
+ },
+ "update.overlay.error.canceledDescription": {
+ "message": "Оновлення до {target} було скасовано до його завершення."
+ },
+ "update.overlay.error.failTitle": {
+ "message": "Не вдалося встановити оновлення"
+ },
+ "update.overlay.error.failDescription": {
+ "message": "Не вдалося встановити оновлення до {target}."
+ },
+ "update.overlay.error.unknownMessage": {
+ "message": "Невідома помилка"
+ },
+ "update.overlay.error.targetVersion": {
+ "message": "v{version}"
+ },
+ "update.overlay.error.targetFallback": {
+ "message": "нової версії"
+ },
+ "update.error.loadStateTitle": {
+ "message": "Помилка завантаження стану оновлення"
+ },
+ "update.error.triggerTitle": {
+ "message": "Помилка запуску оновлення"
+ },
+ "update.page.versionLine": {
+ "message": "Оновлення клієнта до версії {version}."
+ },
+ "update.page.versionLineGeneric": {
+ "message": "Оновлення клієнта."
+ },
+ "update.page.outdated": {
+ "message": "Ваша версія клієнта старіша за версію для автооновлення, задану в Management."
+ },
+ "update.page.status.running": {
+ "message": "Оновлення"
+ },
+ "update.page.status.timeout": {
+ "message": "Час очікування оновлення минув. Будь ласка, спробуйте ще раз."
+ },
+ "update.page.status.canceled": {
+ "message": "Оновлення скасовано."
+ },
+ "update.page.status.failed": {
+ "message": "Помилка оновлення: {message}"
+ },
+ "update.page.status.unknownError": {
+ "message": "невідома помилка оновлення"
+ },
+ "update.page.failedTitle": {
+ "message": "Помилка оновлення"
+ },
+ "update.page.timeoutMessage": {
+ "message": "Час очікування оновлення минув."
+ },
+ "update.page.dontClose": {
+ "message": "Будь ласка, не закривайте це вікно."
+ },
+ "update.page.updating": {
+ "message": "Оновлення…"
+ },
+ "update.page.complete": {
+ "message": "Оновлення завершено"
+ },
+ "update.page.failed": {
+ "message": "Помилка оновлення"
+ },
+ "window.title.settings": {
+ "message": "Налаштування"
+ },
+ "window.title.signIn": {
+ "message": "Вхід"
+ },
+ "window.title.sessionExpiration": {
+ "message": "Термін дії сеансу закінчується"
+ },
+ "window.title.updating": {
+ "message": "Оновлення"
+ },
+ "window.title.welcome": {
+ "message": "Ласкаво просимо до NetBird"
+ },
+ "window.title.error": {
+ "message": "Помилка"
+ },
+ "welcome.title": {
+ "message": "Знайдіть NetBird в області сповіщень"
+ },
+ "welcome.titleMac": {
+ "message": "Знайдіть NetBird у рядку меню"
+ },
+ "welcome.description": {
+ "message": "NetBird працює в області сповіщень. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+ },
+ "welcome.descriptionMac": {
+ "message": "NetBird працює в рядку меню. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+ },
+ "welcome.continue": {
+ "message": "Продовжити"
+ },
+ "welcome.back": {
+ "message": "Назад"
+ },
+ "welcome.management.title": {
+ "message": "Налаштування NetBird"
+ },
+ "welcome.management.description": {
+ "message": "Натисніть «Продовжити», щоб розпочати, або виберіть Власний сервер, якщо у вас є власний сервер NetBird."
+ },
+ "welcome.management.cloud.title": {
+ "message": "NetBird Cloud"
+ },
+ "welcome.management.cloud.description": {
+ "message": "Використовуйте наш хмарний сервіс. Налаштування не потрібне."
+ },
+ "welcome.management.selfHosted.title": {
+ "message": "Власний сервер"
+ },
+ "welcome.management.selfHosted.description": {
+ "message": "Підключіться до власного сервера керування."
+ },
+ "welcome.management.urlLabel": {
+ "message": "URL-адреса сервера керування"
+ },
+ "welcome.management.urlPlaceholder": {
+ "message": "https://netbird.selfhosted.com:443"
+ },
+ "welcome.management.urlInvalid": {
+ "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+ },
+ "welcome.management.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або вашу мережу, а потім продовжуйте, якщо ви впевнені, що вона правильна."
+ },
+ "welcome.management.checking": {
+ "message": "Перевірка…"
+ },
+ "browserLogin.title": {
+ "message": "Завершіть вхід у браузері"
+ },
+ "browserLogin.notSeeing": {
+ "message": "Ми відкрили вкладку браузера, щоб ви могли завершити вхід. Не бачите її?"
+ },
+ "browserLogin.tryAgain": {
+ "message": "Спробувати ще раз"
+ },
+ "browserLogin.openFailedTitle": {
+ "message": "Помилка відкриття браузера"
+ },
+ "sessionExpiration.title": {
+ "message": "Термін дії сеансу невдовзі закінчиться"
+ },
+ "sessionExpiration.titleLater": {
+ "message": "Термін дії вашого сеансу закінчиться"
+ },
+ "sessionExpiration.description": {
+ "message": "Цей пристрій невдовзі буде відключено. Поновіть сеанс, увійшовши через браузер."
+ },
+ "sessionExpiration.descriptionLater": {
+ "message": "Вхід через браузер підтримує підключення цього пристрою до вашої мережі."
+ },
+ "sessionExpiration.stay": {
+ "message": "Продовжити сеанс"
+ },
+ "sessionExpiration.authenticate": {
+ "message": "Увійти"
+ },
+ "sessionExpiration.logout": {
+ "message": "Вийти"
+ },
+ "sessionExpiration.expired": {
+ "message": "Термін дії сеансу закінчився"
+ },
+ "sessionExpiration.expiredDescription": {
+ "message": "Пристрій відключено. Пройдіть автентифікацію у браузері, щоб перепідключитися."
+ },
+ "sessionExpiration.close": {
+ "message": "Закрити"
+ },
+ "sessionExpiration.extendFailedTitle": {
+ "message": "Помилка продовження сеансу"
+ },
+ "sessionExpiration.logoutFailedTitle": {
+ "message": "Помилка виходу"
+ },
+ "peers.search.placeholder": {
+ "message": "Пошук за ім’ям або IP"
+ },
+ "peers.filter.all": {
+ "message": "Усі"
+ },
+ "peers.filter.online": {
+ "message": "Онлайн"
+ },
+ "peers.filter.offline": {
+ "message": "Офлайн"
+ },
+ "peers.empty.title": {
+ "message": "Немає доступних пірів"
+ },
+ "peers.empty.description": {
+ "message": "У вас немає доступних пірів або доступу до жодного з них."
+ },
+ "peers.details.domain": {
+ "message": "Домен"
+ },
+ "peers.details.netbirdIp": {
+ "message": "NetBird IP"
+ },
+ "peers.details.netbirdIpv6": {
+ "message": "NetBird IPv6"
+ },
+ "peers.details.publicKey": {
+ "message": "Публічний ключ"
+ },
+ "peers.details.connection": {
+ "message": "Підключення"
+ },
+ "peers.details.latency": {
+ "message": "Затримка"
+ },
+ "peers.details.lastHandshake": {
+ "message": "Останнє рукостискання"
+ },
+ "peers.details.statusSince": {
+ "message": "Останнє оновлення підключення"
+ },
+ "peers.details.bytes": {
+ "message": "Байти"
+ },
+ "peers.details.bytesSent": {
+ "message": "Надіслано"
+ },
+ "peers.details.bytesReceived": {
+ "message": "Отримано"
+ },
+ "peers.details.localIce": {
+ "message": "Локальний ICE"
+ },
+ "peers.details.remoteIce": {
+ "message": "Віддалений ICE"
+ },
+ "peers.details.never": {
+ "message": "Ніколи"
+ },
+ "peers.details.justNow": {
+ "message": "Щойно"
+ },
+ "peers.details.refresh": {
+ "message": "Оновити"
+ },
+ "peers.status.connected": {
+ "message": "Підключено"
+ },
+ "peers.status.connecting": {
+ "message": "Підключення"
+ },
+ "peers.status.disconnected": {
+ "message": "Відключено"
+ },
+ "peers.details.relayAddress": {
+ "message": "Ретранслятор"
+ },
+ "peers.details.networks": {
+ "message": "Ресурси"
+ },
+ "peers.details.relayed": {
+ "message": "Через ретранслятор"
+ },
+ "peers.details.p2p": {
+ "message": "P2P"
+ },
+ "peers.details.rosenpass": {
+ "message": "Rosenpass увімкнено"
+ },
+ "networks.search.placeholder": {
+ "message": "Пошук за мережею або доменом"
+ },
+ "networks.filter.all": {
+ "message": "Усі"
+ },
+ "networks.filter.active": {
+ "message": "Активні"
+ },
+ "networks.filter.overlapping": {
+ "message": "Перетинаються"
+ },
+ "networks.empty.title": {
+ "message": "Немає доступних ресурсів"
+ },
+ "networks.empty.description": {
+ "message": "У вас немає доступних мережевих ресурсів або доступу до жодного з них."
+ },
+ "networks.selected": {
+ "message": "Вибрано"
+ },
+ "networks.unselected": {
+ "message": "Не вибрано"
+ },
+ "networks.ips.heading": {
+ "message": "Визначені IP-адреси"
+ },
+ "networks.bulk.selectionCount": {
+ "message": "Активні: {selected} з {total}"
+ },
+ "networks.bulk.enableAll": {
+ "message": "Увімкнути всі"
+ },
+ "networks.bulk.disableAll": {
+ "message": "Вимкнути всі"
+ },
+ "exitNodes.search.placeholder": {
+ "message": "Пошук вихідних вузлів"
+ },
+ "exitNodes.none": {
+ "message": "Немає"
+ },
+ "exitNodes.empty.title": {
+ "message": "Немає доступних вихідних вузлів"
+ },
+ "exitNodes.empty.description": {
+ "message": "Цьому піру не надано жодного вихідного вузла."
+ },
+ "exitNodes.card.title": {
+ "message": "Вихідний вузол"
+ },
+ "exitNodes.card.statusActive": {
+ "message": "Активний"
+ },
+ "exitNodes.card.statusInactive": {
+ "message": "Неактивний"
+ },
+ "exitNodes.dropdown.noneTitle": {
+ "message": "Немає"
+ },
+ "exitNodes.dropdown.noneDescription": {
+ "message": "Пряме підключення без вихідного вузла"
+ },
+ "quickActions.connect": {
+ "message": "Підключитися"
+ },
+ "quickActions.disconnect": {
+ "message": "Відключитися"
+ },
+ "daemon.unavailable.title": {
+ "message": "Служба NetBird не запущена"
+ },
+ "daemon.unavailable.description": {
+ "message": "Програма перепідключиться автоматично, щойно служба запрацює."
+ },
+ "daemon.unavailable.docsLink": {
+ "message": "Документація"
+ },
+ "daemon.outdated.title": {
+ "message": "Клієнт NetBird застарів"
+ },
+ "daemon.outdated.description": {
+ "message": "Новий графічний інтерфейс несумісний зі старою версією клієнта NetBird. Оновіть клієнт, щоб використовувати нову програму."
+ },
+ "daemon.outdated.download": {
+ "message": "Завантажити останню версію"
+ },
+ "error.jwt_clock_skew": {
+ "message": "Помилка входу: годинник цього пристрою не синхронізовано із сервером. Будь ласка, синхронізуйте системний годинник і спробуйте знову."
+ },
+ "error.jwt_expired": {
+ "message": "Термін дії вашого токена входу закінчився. Будь ласка, увійдіть знову."
+ },
+ "error.jwt_signature_invalid": {
+ "message": "Помилка входу: недійсний підпис токена. Будь ласка, зверніться до адміністратора."
+ },
+ "error.session_expired": {
+ "message": "Термін дії вашого сеансу закінчився. Будь ласка, увійдіть знову."
+ },
+ "error.invalid_setup_key": {
+ "message": "Setup Key відсутній або недійсний."
+ },
+ "error.permission_denied": {
+ "message": "Вхід відхилено сервером."
+ },
+ "error.daemon_unreachable": {
+ "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба."
+ },
+ "error.unknown": {
+ "message": "Помилка операції."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird не зміг запросити в системи привілеї, необхідні для внесення змін. Замість цього виконайте:"
+ },
+ "error.elevation_failed": {
+ "message": "Не вдалося застосувати зміни з підвищеними привілеями. Замість цього виконайте:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "прав root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "прав адміністратора"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Потребує {actor}. Замість цього виконайте:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Ви можете вимкнути це, але щоб увімкнути знову, знадобиться {actor}:"
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Очікування авторизації…"
+ }
+}
diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json
index 542b2b045..b1ff3370d 100644
--- a/client/ui/i18n/locales/zh-CN/common.json
+++ b/client/ui/i18n/locales/zh-CN/common.json
@@ -401,9 +401,6 @@
"networks.bulk.label": {
"message": "切换所有可见资源"
},
- "settings.nav.label": {
- "message": "设置部分"
- },
"profile.switch.title": {
"message": "切换到配置文件“{name}”?"
},
@@ -497,6 +494,9 @@
"settings.error.debugBundleTitle": {
"message": "创建调试包失败"
},
+ "settings.nav.label": {
+ "message": "设置部分"
+ },
"settings.tabs.general": {
"message": "常规"
},
@@ -764,7 +764,19 @@
"message": "匿名化敏感信息"
},
"settings.troubleshooting.anonymize.help": {
- "message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。"
+ "message": "隐藏 IP 地址、域名和其他敏感值。"
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。"
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "无"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "默认"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "严格"
},
"settings.troubleshooting.systemInfo.label": {
"message": "包含系统信息"
@@ -1338,5 +1350,29 @@
},
"error.unknown": {
"message": "操作失败。"
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
+ },
+ "error.elevation_failed": {
+ "message": "即使使用提升的权限也无法应用此更改。请改为运行:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "root 权限"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "管理员权限"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "需要{actor}。请改为运行:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "您可以关闭此项,但重新开启需要{actor}。"
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "您可以开启此项,但再次关闭需要{actor}。"
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "正在等待授权…"
}
}
diff --git a/client/ui/main.go b/client/ui/main.go
index 5f740f5ec..5652efcf2 100644
--- a/client/ui/main.go
+++ b/client/ui/main.go
@@ -8,6 +8,7 @@ import (
"flag"
"io/fs"
"log"
+ "os"
"runtime"
"strings"
@@ -79,6 +80,14 @@ func init() {
}
func main() {
+ // The one-shot that applies the settings the daemon restricts to
+ // root/administrator, which this binary runs itself as under the platform's
+ // elevation prompt. Handled before anything GUI so no window, tray or
+ // single-instance lock is involved.
+ if services.IsPrivilegedSettingsRun(os.Args[1:]) {
+ os.Exit(runPrivilegedSettings(os.Args[1:]))
+ }
+
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
conn := NewConn(daemonAddr)
@@ -139,13 +148,11 @@ func main() {
prefStore: prefStore,
})
- window := newMainWindow(app, prefStore)
-
- // Settings is created eagerly (hidden) so the first gear click paints
- // instantly and React keeps per-tab state across reopens. The other
- // auxiliary windows stay lazy + destroy-on-close so Wails's macOS
- // dock-reopen handler can't resurrect them.
- windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
+ windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
+ windowManager.SetMainFactory(func(startURL string) *application.WebviewWindow {
+ return newMainWindow(app, prefStore, windowManager, startURL)
+ })
+ registerDockReopenHook(app, windowManager)
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
// position across hide -> show, dropping them top-left. Gate Go-side
// re-centering on that environment; nil leaves placement to the WM on full
@@ -168,7 +175,7 @@ func main() {
// RegisterStatusNotifierItem hits a watcher we control.
startStatusNotifierWatcher()
- tray = NewTray(app, window, TrayServices{
+ tray = NewTray(app, nil, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
@@ -279,10 +286,12 @@ func newApplication(onSecondInstance func()) *application.App {
ActivationPolicy: application.ActivationPolicyAccessory,
},
Linux: application.LinuxOptions{
- ProgramName: "netbird",
+ ProgramName: "netbird",
+ DisableQuitOnLastWindowClosed: true,
},
Windows: application.WindowsOptions{
- WndProcInterceptor: endSessionInterceptor(),
+ WndProcInterceptor: endSessionInterceptor(),
+ DisableQuitOnLastWindowClosed: true,
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "io.netbird.ui",
@@ -338,9 +347,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.compat))
}
-// newMainWindow creates the hidden main window, sized to the user's last view
-// mode, and installs the hide-on-close and macOS dock-reopen hooks.
-func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
+func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager, startURL string) *application.WebviewWindow {
// Width matches the last view mode so Advanced-mode users don't see the
// window pop from 380px to 900px on launch. Height is mode-agnostic.
initialWidth := 380
@@ -357,7 +364,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
InitialPosition: application.WindowCentered,
Hidden: true,
BackgroundColour: services.WindowBackgroundColour,
- URL: "/",
+ URL: startURL,
DisableResize: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
@@ -368,29 +375,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
},
})
- // Hide instead of quit on close; "really quit" is reached via tray -> Quit.
- window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
+ window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
if services.ShuttingDown() {
return
}
- e.Cancel()
- window.Hide()
+ wm.ForgetMain()
})
- // On macOS, Wails' default applicationShouldHandleReopen handler Show()s
- // every hidden window on dock-icon click, resurrecting hide-on-close
- // surfaces like Settings. Cancel it in a hook (hooks run before listeners)
- // and show only the main window. No-op elsewhere — the event never fires.
- if runtime.GOOS == "darwin" {
- app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
- e.Cancel()
- if e.Context().HasVisibleWindows() {
- return
- }
- window.Show()
- window.Focus()
- })
- }
-
return window
}
+
+func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
+ if runtime.GOOS != "darwin" {
+ return
+ }
+ app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
+ if e.Context().HasVisibleWindows() {
+ return
+ }
+ e.Cancel()
+ wm.ShowMain()
+ })
+}
diff --git a/client/ui/privileged_settings.go b/client/ui/privileged_settings.go
new file mode 100644
index 000000000..1e8b4bbf6
--- /dev/null
+++ b/client/ui/privileged_settings.go
@@ -0,0 +1,27 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/ui/services"
+)
+
+// The one-shot mode this binary runs itself in, elevated, to apply the settings the
+// daemon restricts to root/administrator. It is handled before anything GUI, so no
+// window, tray or single-instance lock is involved.
+//
+// Only the wiring is here: what the mode accepts and does lives beside the code
+// that asks for it, in services.RunPrivilegedSettings, so the settings it will
+// apply are declared once. There is nothing privileged about the mode itself; it
+// sends the same request the frontend would have sent, and the daemon authorizes it
+// from the identity the kernel reports on the control channel exactly as it does
+// for `sudo netbird up`.
+func runPrivilegedSettings(args []string) int {
+ return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
+ if addr == "" {
+ addr = DaemonAddr()
+ }
+ return NewConn(addr).Client()
+ })
+}
diff --git a/client/ui/services/guarded.go b/client/ui/services/guarded.go
new file mode 100644
index 000000000..f425428b5
--- /dev/null
+++ b/client/ui/services/guarded.go
@@ -0,0 +1,231 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/elevate"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// The command line of the one-shot mode this binary runs itself in, elevated, to
+// apply a setting the daemon restricts to root/administrator. The setting flags
+// spell the same words as `netbird up`, so the command a user is shown and what
+// runs behind the prompt read alike. Parsed in oneshot.go.
+const (
+ FlagApplyPrivilegedSettings = "apply-privileged-settings"
+ FlagDaemonAddr = "daemon-addr"
+ FlagProfile = "profile"
+ FlagUser = "user"
+ FlagLogLevel = "log-level"
+ FlagManagementURL = "management-url"
+ FlagAllowServerSSH = "allow-server-ssh"
+ FlagEnableSSHRoot = "enable-ssh-root"
+ FlagDisableSSHAuth = "disable-ssh-auth"
+)
+
+// Error codes for the ways asking for privileges can fail.
+const (
+ CodeElevationUnavailable = "elevation_unavailable"
+ CodeElevationFailed = "elevation_failed"
+)
+
+// elevationTimeout bounds the wait for a prompt and the change behind it, so a
+// dialog nobody answers does not leave its control disabled for the session. Long
+// enough to find a password manager, and no shorter than the platforms' own prompt
+// timeouts: Windows gives up on its consent dialog after two minutes by itself.
+//
+// It always ends our waiting, and not always the prompt: Security.framework offers
+// no way to withdraw a request, so on macOS the system's own timeout is what closes
+// the dialog.
+const elevationTimeout = 5 * time.Minute
+
+// elevator raises the platform's privilege prompt and runs the change behind it.
+// An interface so tests can answer without a prompt.
+type elevator interface {
+ // Run runs this binary again, elevated, with the given arguments.
+ Run(ctx context.Context, args ...string) error
+ // Available reports whether there is a prompt to raise on this host at all.
+ Available() bool
+}
+
+// osElevator is the real thing: see the elevate package.
+type osElevator struct{}
+
+func (osElevator) Run(ctx context.Context, args ...string) error {
+ return elevate.Run(ctx, args...)
+}
+
+func (osElevator) Available() bool {
+ return elevate.Available()
+}
+
+// SaveOutcome reports what became of a change that needed authorization.
+//
+// A declined prompt is a result, not an error: the user was asked and said no, so
+// nothing was applied and nothing went wrong. Reporting it as an error would have
+// every cancelled prompt logged as one.
+type SaveOutcome struct {
+ // Declined is set when the user dismissed the authorization prompt, or was
+ // refused by policy. Nothing was changed.
+ Declined bool `json:"declined"`
+}
+
+// GuardedSettings is the subset of the config the daemon restricts to
+// root/administrator. Only the fields that are set are changed: a nil pointer, or
+// an empty management URL, leaves that setting alone.
+//
+// The management URL is in here because pointing a host with the SSH server
+// running at another management identity hands the decision of who may open a
+// shell on it to whoever runs that server, which is the same power as enabling
+// the SSH server in the first place.
+type GuardedSettings struct {
+ ProfileName string `json:"profileName"`
+ Username string `json:"username"`
+ ManagementURL string `json:"managementUrl,omitempty"`
+ ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
+ EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
+ DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
+}
+
+// guardedSetting is one setting to change, in the two spellings this needs: the
+// one-shot's own flag, and the `netbird up` flag that does the same thing from a
+// terminal, for when there is no prompt to raise.
+type guardedSetting struct {
+ arg string
+ flag string
+}
+
+// SetGuardedSettings applies settings the daemon refuses from an unprivileged
+// caller, by having the operating system run this binary again, elevated, to send
+// the same request the frontend would have sent itself.
+//
+// The user authorizes it at the platform's own prompt: the UAC consent dialog,
+// the macOS authentication dialog, or the polkit agent's. Any credentials are the
+// operating system's business; NetBird neither sees nor asks for them. Nothing
+// about the daemon's rules changes, and the elevated process is authorized like
+// any other privileged caller, from the identity the kernel reports for it.
+//
+// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
+// no prompt to raise, or the elevated run failed, the error carries the command
+// that does the same thing from a terminal.
+func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
+ settings := guardedSettings(p)
+ if len(settings) == 0 {
+ return SaveOutcome{}, &ClientError{
+ Code: CodeElevationFailed,
+ Short: "no setting to apply",
+ Long: "no setting to apply",
+ }
+ }
+
+ // The elevated run has no window and, on Linux, an environment pkexec has
+ // cleared, so what it writes to stderr is all there is to go on. It follows
+ // this process's level so that starting the app with --log-level debug says
+ // something about the run behind the prompt too.
+ args := append([]string{
+ "--" + FlagApplyPrivilegedSettings,
+ "--" + FlagDaemonAddr, s.daemonAddr,
+ "--" + FlagProfile, p.ProfileName,
+ "--" + FlagUser, p.Username,
+ "--" + FlagLogLevel, log.GetLevel().String(),
+ }, oneShotArgs(settings)...)
+
+ ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
+ defer cancel()
+
+ // These changes hand out shells on this host, so both ends are logged: when the
+ // prompt went up, and what came of it. It is also the only account of a prompt
+ // that was slow to appear or never answered.
+ log.Infof("asking for privileges to apply %s", guardedSummary(p))
+
+ if err := s.elevator.Run(ctx, args...); err != nil {
+ return s.elevationOutcome(err, p)
+ }
+
+ log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
+ return SaveOutcome{}, nil
+}
+
+// elevationOutcome sorts what came back into the one normal ending and the two
+// that need reporting, with the command that does the same thing by hand.
+func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
+ switch {
+ case errors.Is(err, elevate.ErrDeclined):
+ // With the reason: an account that may not elevate at all lands here too,
+ // and the log is the only place that says which it was.
+ log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
+ return SaveOutcome{Declined: true}, nil
+ case errors.Is(err, elevate.ErrUnavailable):
+ log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
+ return SaveOutcome{}, &ClientError{
+ Code: CodeElevationUnavailable,
+ Short: s.classifier.translateShort(CodeElevationUnavailable),
+ Long: err.Error(),
+ Command: guardedCommand(p),
+ }
+ default:
+ log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
+ return SaveOutcome{}, &ClientError{
+ Code: CodeElevationFailed,
+ Short: s.classifier.translateShort(CodeElevationFailed),
+ Long: err.Error(),
+ Command: guardedCommand(p),
+ }
+ }
+}
+
+// guardedSettings renders the settings that are actually being changed, from the
+// same table the one-shot parses them with: see oneshot.go.
+func guardedSettings(p GuardedSettings) []guardedSetting {
+ var settings []guardedSetting
+ for _, field := range guardedFields {
+ value, ok := field.read(p)
+ if !ok {
+ continue
+ }
+ settings = append(settings, guardedSetting{
+ arg: "--" + field.flag + "=" + value,
+ flag: field.up(value),
+ })
+ }
+ return settings
+}
+
+func oneShotArgs(settings []guardedSetting) []string {
+ args := make([]string, 0, len(settings))
+ for _, setting := range settings {
+ args = append(args, setting.arg)
+ }
+ return args
+}
+
+func upFlags(settings []guardedSetting) []string {
+ flags := make([]string, 0, len(settings))
+ for _, setting := range settings {
+ flags = append(flags, setting.flag)
+ }
+ return flags
+}
+
+// guardedCommand is the elevated command line equivalent to the requested
+// change, the same shape the daemon names in its own refusals.
+func guardedCommand(p GuardedSettings) string {
+ settings := guardedSettings(p)
+ if len(settings) == 0 {
+ return ""
+ }
+ return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
+}
+
+// guardedSummary names the change for the log.
+func guardedSummary(p GuardedSettings) string {
+ return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
+}
diff --git a/client/ui/services/guarded_test.go b/client/ui/services/guarded_test.go
new file mode 100644
index 000000000..42c00ce4f
--- /dev/null
+++ b/client/ui/services/guarded_test.go
@@ -0,0 +1,355 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/elevate"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// A Unix socket, so the daemon address is one that carries a caller's identity and
+// elevation is worth offering at all: see Settings.canElevate.
+const testDaemonAddr = "unix:///var/run/netbird.sock"
+
+// storedManagementURL is what the stub daemon already holds, so that a request
+// naming a different one is a change: see Settings.guardedChanges.
+const storedManagementURL = "https://stored.example.com"
+
+// stubElevator stands in for the platform's prompt: it records what would have run
+// and answers with a fixed outcome.
+type stubElevator struct {
+ outcome error
+ available bool
+ calls [][]string
+}
+
+func (e *stubElevator) Run(_ context.Context, args ...string) error {
+ e.calls = append(e.calls, args)
+ return e.outcome
+}
+
+func (e *stubElevator) Available() bool { return e.available }
+
+// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
+// any other call panics rather than passing quietly.
+type stubDaemon struct {
+ proto.DaemonServiceClient
+ setConfig func(*proto.SetConfigRequest) error
+ // stored is what GetConfig reports, which is what a refused request's guarded
+ // settings are compared against.
+ stored *proto.GetConfigResponse
+ requests []*proto.SetConfigRequest
+}
+
+func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
+ d.requests = append(d.requests, in)
+ if err := d.setConfig(in); err != nil {
+ return nil, err
+ }
+ return &proto.SetConfigResponse{}, nil
+}
+
+func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
+ return d.stored, nil
+}
+
+type stubConn struct{ client proto.DaemonServiceClient }
+
+func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
+
+// privilegeRefusal is the error the daemon raises for a change it restricts to
+// root, detail and all: see server.privilegeError.
+func privilegeRefusal(t *testing.T) error {
+ t.Helper()
+
+ st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
+ WithDetails(&errdetails.ErrorInfo{
+ Reason: ipcauth.ErrorReasonPrivilegeRequired,
+ Domain: ipcauth.ErrorDomain,
+ Metadata: map[string]string{
+ ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
+ ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
+ },
+ })
+ require.NoError(t, err, "build the refusal detail")
+ return st.Err()
+}
+
+func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
+ t.Helper()
+
+ elev := &stubElevator{outcome: outcome, available: true}
+ return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
+}
+
+// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
+// for want of privileges and accepts anything after it. Its stored config holds
+// another management server and no SSH grants, so a request naming either is a
+// change rather than a restatement.
+func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
+ t.Helper()
+
+ refusal := privilegeRefusal(t)
+ daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
+ daemon.setConfig = func(*proto.SetConfigRequest) error {
+ if len(daemon.requests) == 1 {
+ return refusal
+ }
+ return nil
+ }
+ return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
+}
+
+func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
+ s, elev := settingsWithElevation(t, nil)
+
+ root := true
+ outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
+ ProfileName: "work",
+ Username: "vma",
+ EnableSSHRoot: &root,
+ })
+ require.NoError(t, err)
+ assert.False(t, outcome.Declined, "the prompt was answered")
+
+ want := []string{
+ "--" + FlagApplyPrivilegedSettings,
+ "--" + FlagDaemonAddr, testDaemonAddr,
+ "--" + FlagProfile, "work",
+ "--" + FlagUser, "vma",
+ "--" + FlagLogLevel, log.GetLevel().String(),
+ "--" + FlagEnableSSHRoot + "=true",
+ }
+ require.Len(t, elev.calls, 1, "one prompt for one change")
+ assert.Equal(t, want, elev.calls[0], "elevated arguments")
+
+ // argv[1] is what the polkit action is pinned to, so the marker has to stay
+ // first however the rest of the line grows.
+ assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
+}
+
+// Turning a setting off has to be as explicit as turning it on: a bare flag would
+// read as "on" to the one-shot's parser.
+func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
+ s, elev := settingsWithElevation(t, nil)
+
+ off := false
+ _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
+ ProfileName: "default",
+ ServerSSHAllowed: &off,
+ DisableSSHAuth: &off,
+ })
+ require.NoError(t, err)
+
+ args := elev.calls[0]
+ assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
+ assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
+ assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
+}
+
+func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
+ s, elev := settingsWithElevation(t, nil)
+
+ _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
+ ProfileName: "default",
+ ManagementURL: "https://mgmt.example.com:33073",
+ })
+ require.NoError(t, err)
+
+ assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
+ "the management URL to point the profile at")
+}
+
+func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
+ s, elev := settingsWithElevation(t, nil)
+
+ _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
+
+ require.Error(t, err, "nothing to apply is not something to prompt for")
+ assert.Empty(t, elev.calls, "no prompt at all")
+}
+
+// A declined prompt is the one ending that is not an error: reporting it as one
+// would have every cancelled prompt logged as a failure.
+func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
+ s, _ := settingsWithElevation(t, elevate.ErrDeclined)
+
+ root := true
+ outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
+ ProfileName: "default",
+ EnableSSHRoot: &root,
+ })
+
+ require.NoError(t, err, "the user was asked and answered; nothing went wrong")
+ assert.True(t, outcome.Declined, "nothing was applied")
+}
+
+func TestSetGuardedSettingsMapsFailures(t *testing.T) {
+ tests := []struct {
+ name string
+ outcome error
+ wantCode string
+ }{
+ {
+ // Nothing to raise a prompt with: the user needs the command.
+ name: "no mechanism falls back to the command",
+ outcome: elevate.ErrUnavailable,
+ wantCode: CodeElevationUnavailable,
+ },
+ {
+ name: "a failed run falls back to the command",
+ outcome: errors.New("elevated netbird exited with 1"),
+ wantCode: CodeElevationFailed,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s, _ := settingsWithElevation(t, tt.outcome)
+
+ root := true
+ _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
+ ProfileName: "default",
+ EnableSSHRoot: &root,
+ })
+
+ var clientErr *ClientError
+ require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
+ assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
+ assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
+ "the setting in the fallback command")
+ assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
+ })
+ }
+}
+
+// Changing the management URL is only privileged while the host runs the SSH
+// server, which no control can know up front, so the refusal is what triggers the
+// prompt. The original request goes again afterwards, so the fields the one-shot
+// does not understand are applied too.
+func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
+ elev := &stubElevator{available: true}
+ s, daemon := settingsRefusingOnce(t, elev)
+
+ mtu := int64(1280)
+ outcome, err := s.SetConfig(context.Background(), SetConfigParams{
+ ProfileName: "default",
+ ManagementURL: "https://mgmt.example.com",
+ MTU: &mtu,
+ })
+ require.NoError(t, err)
+ assert.False(t, outcome.Declined, "the prompt was answered")
+
+ require.Len(t, elev.calls, 1, "one prompt")
+ assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
+ "the guarded part of the request")
+ require.Len(t, daemon.requests, 2, "the refused request and the retry")
+ assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
+ "the retry carries the rest of the request, which the one-shot does not understand")
+}
+
+func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
+ elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
+ s, daemon := settingsRefusingOnce(t, elev)
+
+ outcome, err := s.SetConfig(context.Background(), SetConfigParams{
+ ProfileName: "default",
+ ManagementURL: "https://mgmt.example.com",
+ })
+
+ require.NoError(t, err, "a declined prompt is not an error")
+ assert.True(t, outcome.Declined, "nothing was applied")
+ assert.Len(t, daemon.requests, 1, "only the refused request")
+}
+
+// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
+// the guidance that was there before elevation existed.
+func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
+ elev := &stubElevator{available: false}
+ s, _ := settingsRefusingOnce(t, elev)
+
+ _, err := s.SetConfig(context.Background(), SetConfigParams{
+ ProfileName: "default",
+ ManagementURL: "https://mgmt.example.com",
+ })
+
+ var clientErr *ClientError
+ require.ErrorAs(t, err, &clientErr)
+ assert.Equal(t, "privilege_required", clientErr.Code, "error code")
+ assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
+ "the daemon's own command")
+ assert.Empty(t, elev.calls, "no prompt where there is none to raise")
+}
+
+// One authorization must buy only the change the user made. A settings form
+// submits every field it holds, so most of a refused request restates what the
+// daemon already has, and elevating those too would apply a guarded setting the
+// user never touched — a value gone stale since the form loaded above all.
+func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
+ elev := &stubElevator{available: true}
+ s, _ := settingsRefusingOnce(t, elev)
+
+ on, off := true, false
+ _, err := s.SetConfig(context.Background(), SetConfigParams{
+ ProfileName: "default",
+ ManagementURL: storedManagementURL,
+ ServerSSHAllowed: &off,
+ EnableSSHRoot: &off,
+ DisableSSHAuth: &on,
+ })
+ require.NoError(t, err)
+
+ require.Len(t, elev.calls, 1, "one prompt")
+ args := elev.calls[0]
+ assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
+ assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
+ "a management URL the daemon already holds")
+ assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
+ assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
+}
+
+// A request that changes no guarded setting has nothing an elevated run could
+// apply, so the refusal must have come from somewhere a prompt cannot reach.
+func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
+ elev := &stubElevator{available: true}
+ s, _ := settingsRefusingOnce(t, elev)
+
+ off := false
+ _, err := s.SetConfig(context.Background(), SetConfigParams{
+ ProfileName: "default",
+ ManagementURL: storedManagementURL,
+ ServerSSHAllowed: &off,
+ })
+
+ var clientErr *ClientError
+ require.ErrorAs(t, err, &clientErr)
+ assert.Equal(t, "privilege_required", clientErr.Code, "error code")
+ assert.Empty(t, elev.calls, "no prompt for a change nobody made")
+}
+
+// A refusal with nothing in the request the one-shot could apply: the daemon
+// cannot see who is calling, and being root would not help either.
+func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
+ elev := &stubElevator{available: true}
+ s, _ := settingsRefusingOnce(t, elev)
+
+ _, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
+
+ var clientErr *ClientError
+ require.ErrorAs(t, err, &clientErr)
+ assert.Equal(t, "privilege_required", clientErr.Code, "error code")
+ assert.Empty(t, elev.calls, "no prompt")
+}
diff --git a/client/ui/services/oneshot.go b/client/ui/services/oneshot.go
new file mode 100644
index 000000000..d20b390cd
--- /dev/null
+++ b/client/ui/services/oneshot.go
@@ -0,0 +1,239 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/elevate"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// The other end of SetGuardedSettings: the mode this binary runs itself in,
+// elevated, to apply the settings the daemon restricts to root/administrator.
+//
+// Both ends are here on purpose. What may be changed this way is an allowlist, and
+// an allowlist declared twice is one that will eventually disagree with itself, so
+// the arguments are rendered and parsed from a single table: guardedFields. Adding
+// a setting is one row; nothing generic passes through, and no field outside the
+// table can be reached with an elevated request no matter what lands on the command
+// line.
+
+// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
+// because the user has just waited for an authentication dialog, and a failure here
+// costs them the entire round trip.
+const oneShotTimeout = 30 * time.Second
+
+// Exit codes the parent reads where the platform gives it one.
+const (
+ exitOK = 0
+ exitFailure = 1
+ exitUsage = 2
+)
+
+// guardedField is one setting the one-shot understands, in the two spellings it
+// needs and with the two halves of its plumbing.
+type guardedField struct {
+ // flag names it on the one-shot's command line.
+ flag string
+ usage string
+ // read returns the value to send and whether the caller asked for this setting
+ // at all.
+ read func(GuardedSettings) (string, bool)
+ // write parses a value from the command line onto the request. It is the only
+ // thing that validates the value, so it fails on anything it does not
+ // recognise rather than guessing.
+ write func(*proto.SetConfigRequest, string) error
+ // up renders the equivalent `netbird up` flag, for the fallback command shown
+ // when there is no prompt to raise.
+ up func(value string) string
+}
+
+var guardedFields = []guardedField{
+ {
+ flag: FlagManagementURL,
+ usage: "Management server the profile registers with.",
+ read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
+ write: func(req *proto.SetConfigRequest, value string) error {
+ // Parsed with the config layer's own parser, so what the elevated run
+ // accepts cannot drift from what the daemon would store.
+ if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
+ return err
+ }
+ req.ManagementUrl = value
+ return nil
+ },
+ // The daemon names this one as `-m ` in its own refusals.
+ up: func(value string) string { return "-m " + value },
+ },
+ boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
+ func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
+ func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
+ boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
+ func(p GuardedSettings) *bool { return p.EnableSSHRoot },
+ func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
+ boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
+ func(p GuardedSettings) *bool { return p.DisableSSHAuth },
+ func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
+}
+
+// fieldValue is a flag that remembers whether it was given, and requires a value:
+// the renderer always writes one, so a bare flag is a caller that got it wrong.
+type fieldValue struct {
+ set bool
+ value string
+}
+
+func (v *fieldValue) String() string {
+ if v == nil {
+ return ""
+ }
+ return v.value
+}
+
+func (v *fieldValue) Set(value string) error {
+ v.set, v.value = true, value
+ return nil
+}
+
+// boolField describes a setting that is on or off. The value is always spelled out,
+// so that turning a setting off is as unambiguous as turning it on and a flag with
+// no value is a mistake rather than an "on".
+func boolField(
+ name, usage string,
+ read func(GuardedSettings) *bool,
+ write func(*proto.SetConfigRequest, *bool),
+) guardedField {
+ return guardedField{
+ flag: name,
+ usage: usage,
+ read: func(p GuardedSettings) (string, bool) {
+ value := read(p)
+ if value == nil {
+ return "", false
+ }
+ return strconv.FormatBool(*value), true
+ },
+ write: func(req *proto.SetConfigRequest, value string) error {
+ parsed, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("parse %q as a boolean: %w", value, err)
+ }
+ write(req, &parsed)
+ return nil
+ },
+ up: func(value string) string { return "--" + name + "=" + value },
+ }
+}
+
+// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
+// The flag is a marker rather than a value, so only the bare forms count: reading a
+// value would mean "--flag=false" started it too.
+func IsPrivilegedSettingsRun(args []string) bool {
+ for _, arg := range args {
+ if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
+ return true
+ }
+ }
+ return false
+}
+
+// RunPrivilegedSettings applies the requested settings and returns the process exit
+// code. connect dials the daemon, which is the caller's business because only it
+// knows how this build talks to it.
+//
+// Everything it reports goes to stderr, which is what the parent captures where the
+// platform lets it. On success it says so on standard output, because macOS gives
+// the parent no exit status to read: see elevate.AppliedMarker.
+func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
+ fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
+ fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
+ daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
+ logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
+ profile := fs.String(FlagProfile, "", "Profile to change.")
+ username := fs.String(FlagUser, "", "Owner of the profile.")
+
+ values := make([]fieldValue, len(guardedFields))
+ for i, field := range guardedFields {
+ fs.Var(&values[i], field.flag, field.usage)
+ }
+
+ if err := fs.Parse(args); err != nil {
+ return exitUsage
+ }
+
+ if err := util.InitLog(*logLevel, "console"); err != nil {
+ fmt.Fprintf(os.Stderr, "init log: %v\n", err)
+ return exitFailure
+ }
+
+ req, err := privilegedRequest(*profile, *username, values)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ return exitUsage
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
+ defer cancel()
+
+ if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
+ fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
+ return exitFailure
+ }
+
+ fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
+ return exitOK
+}
+
+// privilegedRequest builds the request from the flags that were given, and refuses
+// one that asks for nothing.
+func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
+ req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
+
+ given := 0
+ for i, field := range guardedFields {
+ if !values[i].set {
+ continue
+ }
+ if err := field.write(req, values[i].value); err != nil {
+ return nil, fmt.Errorf("--%s: %w", field.flag, err)
+ }
+ given++
+ }
+ if given == 0 {
+ return nil, errors.New("no setting to apply")
+ }
+ return req, nil
+}
+
+func applyPrivilegedSettings(
+ ctx context.Context,
+ daemonAddr string,
+ req *proto.SetConfigRequest,
+ connect func(addr string) (proto.DaemonServiceClient, error),
+) error {
+ client, err := connect(daemonAddr)
+ if err != nil {
+ return err
+ }
+ if _, err := client.SetConfig(ctx, req); err != nil {
+ // Unwrapped: the daemon's message is written for a person, and a refusal
+ // elevation cannot fix has to say so where the parent can read it off
+ // stderr.
+ return errors.New(gstatus.Convert(err).Message())
+ }
+ return nil
+}
+
+// interface guard: the one-shot's flags are flag.Value.
+var _ flag.Value = (*fieldValue)(nil)
diff --git a/client/ui/services/oneshot_test.go b/client/ui/services/oneshot_test.go
new file mode 100644
index 000000000..f8eb43066
--- /dev/null
+++ b/client/ui/services/oneshot_test.go
@@ -0,0 +1,151 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "flag"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+func TestIsPrivilegedSettingsRun(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ want bool
+ }{
+ {name: "no arguments"},
+ {name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
+ {name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
+ {
+ name: "among other flags",
+ args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
+ want: true,
+ },
+ // A marker, not a value: the caller never passes one, and reading a value
+ // would mean "--flag=false" started the one-shot too.
+ {name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
+ {name: "unrelated flags", args: []string{"--log-level", "debug"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
+ })
+ }
+}
+
+// What SetGuardedSettings renders has to be what the one-shot reads back, for every
+// setting in the table. This is the property that keeps the two ends of an allowlist
+// from drifting, so it is checked field by field rather than by example.
+func TestGuardedFieldsRoundTrip(t *testing.T) {
+ on, off := true, false
+ tests := []struct {
+ name string
+ settings GuardedSettings
+ want func(*testing.T, *proto.SetConfigRequest)
+ }{
+ {
+ name: "management url",
+ settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
+ want: func(t *testing.T, req *proto.SetConfigRequest) {
+ assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
+ },
+ },
+ {
+ name: "ssh server on",
+ settings: GuardedSettings{ServerSSHAllowed: &on},
+ want: func(t *testing.T, req *proto.SetConfigRequest) {
+ require.NotNil(t, req.ServerSSHAllowed)
+ assert.True(t, *req.ServerSSHAllowed)
+ },
+ },
+ {
+ name: "ssh root off",
+ settings: GuardedSettings{EnableSSHRoot: &off},
+ want: func(t *testing.T, req *proto.SetConfigRequest) {
+ require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
+ assert.False(t, *req.EnableSSHRoot)
+ },
+ },
+ {
+ name: "ssh auth off",
+ settings: GuardedSettings{DisableSSHAuth: &on},
+ want: func(t *testing.T, req *proto.SetConfigRequest) {
+ require.NotNil(t, req.DisableSSHAuth)
+ assert.True(t, *req.DisableSSHAuth)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := parseRendered(t, tt.settings)
+ tt.want(t, req)
+ })
+ }
+}
+
+// A setting nobody asked about must not arrive at the daemon at all: sending its
+// zero value would change it.
+func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
+ on := true
+ req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
+
+ assert.Equal(t, "work", req.GetProfileName(), "profile")
+ require.NotNil(t, req.EnableSSHRoot)
+ assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
+ assert.Nil(t, req.DisableSSHAuth, "untouched setting")
+ assert.Empty(t, req.GetManagementUrl(), "untouched setting")
+}
+
+func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
+ _, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
+ require.Error(t, err, "nothing to apply is not a request worth sending as root")
+}
+
+// A value the table cannot parse is refused rather than guessed at.
+func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
+ values := make([]fieldValue, len(guardedFields))
+ for i, field := range guardedFields {
+ if field.flag != FlagEnableSSHRoot {
+ continue
+ }
+ require.NoError(t, values[i].Set("perhaps"))
+ }
+
+ _, err := privilegedRequest("default", "vma", values)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
+}
+
+// parseRendered puts the settings through both ends: rendered as the arguments the
+// elevated process is given, then parsed by a flag set registered from the same
+// table, which is what the one-shot itself parses them with. Anything hand-rolled
+// here would pin down a parser nothing uses.
+func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
+ t.Helper()
+
+ rendered := guardedSettings(p)
+ require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
+
+ args := make([]string, 0, len(rendered))
+ for _, setting := range rendered {
+ args = append(args, setting.arg)
+ }
+
+ fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
+ values := make([]fieldValue, len(guardedFields))
+ for i, field := range guardedFields {
+ fs.Var(&values[i], field.flag, field.usage)
+ }
+ require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
+
+ req, err := privilegedRequest(p.ProfileName, p.Username, values)
+ require.NoError(t, err)
+ return req
+}
diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go
index 74e6f913c..91aac0467 100644
--- a/client/ui/services/settings.go
+++ b/client/ui/services/settings.go
@@ -44,12 +44,19 @@ type Restrictions struct {
}
// Privilege tells the frontend whether this process may perform the changes the
-// daemon restricts to root/administrator, and carries the command for each so a
-// disabled control can show the way to do it.
+// daemon restricts to root/administrator, whether it can ask the operating
+// system for the privileges instead, and the command for each so a control that
+// can do neither can still show the way.
type Privilege struct {
Privileged bool `json:"privileged"`
- // Actor names what the operation requires ("root", "administrator privileges").
- Actor string `json:"actor"`
+ // ActorKey identifies the principal the operation requires without wording it,
+ // so the frontend can name it in the user's language: see
+ // ipcauth.PrivilegedActorKey. The words are not sent, because English ones
+ // cannot be dropped into a translated sentence.
+ ActorKey string `json:"actorKey"`
+ // CanElevate reports whether a guarded control can offer to authorize the
+ // change through the platform's own prompt: see SetGuardedSettings.
+ CanElevate bool `json:"canElevate"`
// Commands equivalent to the settings the daemon guards, ready to copy.
AllowSSHServer string `json:"allowSshServer"`
EnableSSHRoot string `json:"enableSshRoot"`
@@ -128,6 +135,9 @@ type Settings struct {
// daemonAddr is where the daemon listens, used to tell whether it runs as
// this user and would therefore authorize us: see Privilege.
daemonAddr string
+ // elevator raises the platform's privilege prompt when a change needs more
+ // rights than this process has.
+ elevator elevator
}
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
@@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
conn: conn,
classifier: errorClassifier{translator: translator, prefs: prefs},
daemonAddr: daemonAddr,
+ elevator: osElevator{},
}
}
@@ -180,10 +191,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
}, nil
}
-func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
+func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
cli, err := s.conn.Client()
if err != nil {
- return err
+ return SaveOutcome{}, err
}
req := &proto.SetConfigRequest{
ProfileName: p.ProfileName,
@@ -215,19 +226,92 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
SshJWTCacheTTL: p.SSHJWTCacheTTL,
}
if _, err := cli.SetConfig(ctx, req); err != nil {
+ if _, refused := privilegeErrorInfo(err); refused {
+ return s.setConfigElevated(ctx, p, req, err)
+ }
// Classified so the frontend gets the daemon's guidance instead of the
- // gRPC envelope, which is what a refused privileged change looks like.
- return s.classifier.classify(err)
+ // gRPC envelope.
+ return SaveOutcome{}, s.classifier.classify(err)
}
- return nil
+ return SaveOutcome{}, nil
+}
+
+// setConfigElevated answers a request the daemon refused for want of privileges by
+// asking the user to authorize it, and sending it again if they do. It is the same
+// offer the SSH settings make up front, for the changes a control cannot know are
+// guarded until it is told: repointing a profile at another management server is
+// only privileged while that host runs the SSH server.
+//
+// Two steps, because the elevated one-shot deliberately understands only the
+// settings the daemon guards: it applies those, and the original request then goes
+// through as this user, its privileged parts now asking for nothing that is not
+// already stored. Nothing was applied by the refused attempt — the daemon decides
+// before it writes — so there is no half-applied state to undo either way.
+func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
+ if !s.canElevate() {
+ return SaveOutcome{}, s.classifier.classify(refusal)
+ }
+
+ guarded, err := s.guardedChanges(ctx, p)
+ if err != nil {
+ log.Warnf("cannot tell which guarded settings this request changes: %v", err)
+ return SaveOutcome{}, s.classifier.classify(refusal)
+ }
+ if len(guardedSettings(guarded)) == 0 {
+ // Refused over something no prompt can settle, such as a control channel
+ // that carries no caller identity. Report the daemon's own guidance.
+ return SaveOutcome{}, s.classifier.classify(refusal)
+ }
+
+ outcome, err := s.SetGuardedSettings(ctx, guarded)
+ if err != nil || outcome.Declined {
+ return outcome, err
+ }
+
+ cli, err := s.conn.Client()
+ if err != nil {
+ return SaveOutcome{}, err
+ }
+ if _, err := cli.SetConfig(ctx, req); err != nil {
+ return SaveOutcome{}, s.classifier.classify(err)
+ }
+ return SaveOutcome{}, nil
+}
+
+// guardedChanges is the guarded part of a request, reduced to what it actually
+// changes.
+//
+// A settings form submits every field it holds, so a request restates values the
+// daemon already has. Carrying those into the elevated run would spend one
+// authorization on more than the user asked for, and a value that has gone stale
+// since the form was loaded would spend it on something they never asked about.
+func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
+ stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
+ if err != nil {
+ return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
+ }
+
+ guarded := GuardedSettings{
+ ProfileName: p.ProfileName,
+ Username: p.Username,
+ ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
+ EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
+ DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
+ }
+ // An empty URL leaves the setting alone, which is the daemon's rule too.
+ if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
+ guarded.ManagementURL = p.ManagementURL
+ }
+ return guarded, nil
}
// Privilege reports whether this UI process could carry out the changes the
-// daemon restricts to root/administrator, and the command that performs the one
-// users hit in the SSH settings. It applies the daemon's own rule to what it can
-// see locally, so the frontend can present those controls as unavailable up front
-// instead of letting a save fail. No daemon round-trip, so it also works while the
-// daemon is down.
+// daemon restricts to root/administrator, whether it can instead ask the
+// operating system for the privileges when the user wants one of them, and the
+// command that performs the ones users hit in the SSH settings. It applies the
+// daemon's own rule to what it can see locally, so the frontend can decide up
+// front how to present those controls instead of letting a save fail. No daemon
+// round-trip, so it also works while the daemon is down.
//
// Being root or an elevated administrator is one way. The other is running as the
// daemon's own user while the daemon is unprivileged, which the daemon accepts
@@ -237,26 +321,40 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
func (s *Settings) Privilege() Privilege {
id, err := ipcauth.CurrentProcessIdentity()
if err != nil {
- // Fail closed: report unprivileged, which only ever disables controls.
+ // Fail closed: report unprivileged, which only ever asks for more.
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
- return newPrivilege(false)
+ return s.newPrivilege(false)
}
if id.IsPrivileged() {
- return newPrivilege(true)
+ return s.newPrivilege(true)
}
- return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
+ return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
}
-func newPrivilege(privileged bool) Privilege {
+func (s *Settings) newPrivilege(privileged bool) Privilege {
return Privilege{
Privileged: privileged,
- Actor: ipcauth.PrivilegedActor(),
+ ActorKey: ipcauth.PrivilegedActorKey(),
+ CanElevate: s.canElevate(),
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
}
}
+// canElevate reports whether offering the platform's elevation prompt would get
+// the user anywhere. It needs a mechanism to raise the prompt with and a control
+// channel that tells the daemon who is calling: on loopback TCP the daemon
+// refuses these changes to everybody, root included, so a prompt there would
+// only waste the user's password.
+func (s *Settings) canElevate() bool {
+ if !daemonaddr.CarriesIdentity(s.daemonAddr) {
+ log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
+ return false
+ }
+ return s.elevator.Available()
+}
+
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
cli, err := s.conn.Client()
if err != nil {
@@ -289,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
return r, nil
}
+// changedFlag returns requested only when it differs from what is stored, so a
+// setting the request merely restates is left out of the elevated run.
+func changedFlag(requested *bool, stored bool) *bool {
+ if requested == nil || *requested == stored {
+ return nil
+ }
+ return requested
+}
+
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
managed := cfgResp.GetMDMManagedFields()
if len(managed) == 0 {
diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go
index 5f7aaa7bd..94dba6038 100644
--- a/client/ui/services/windowmanager.go
+++ b/client/ui/services/windowmanager.go
@@ -8,6 +8,7 @@ import (
"sync"
"time"
+ log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -29,6 +30,12 @@ const EventBrowserLoginCancel = "browser-login:cancel"
// EventSettingsOpen tells the mounted settings window which tab to show.
const EventSettingsOpen = "netbird:settings:open"
+const EventWindowPainted = "netbird:window-painted"
+
+const paintedFallback = 2 * time.Second
+
+const headlessTeardownDelay = 2 * time.Second
+
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
// WindowHeight is shared by the main and Settings windows.
@@ -94,9 +101,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
}
}
-// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
-// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
-// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
type WindowManager struct {
app *application.App
mainWindow *application.WebviewWindow
@@ -112,15 +116,35 @@ type WindowManager struct {
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
+ createMu sync.Mutex
+ newMain func(startURL string) *application.WebviewWindow
+ ready map[uint]bool
+ showPending map[uint]bool
+ pendingTab map[uint]string
+ pendingEmits map[uint][]string
+ fallbackTimers map[uint]*time.Timer
+ headlessMain bool
+ headlessTimer *time.Timer
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
}
-// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
-// Settings window is created here (hidden) so the first OpenSettings is instant.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
- s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
+ s := &WindowManager{
+ app: app,
+ mainWindow: mainWindow,
+ translator: translator,
+ prefs: prefs,
+ linuxIcon: linuxIcon,
+ ready: map[uint]bool{},
+ showPending: map[uint]bool{},
+ pendingTab: map[uint]string{},
+ pendingEmits: map[uint][]string{},
+ fallbackTimers: map[uint]*time.Timer{},
+ }
+ s.watchPainted()
+ s.watchTriggerLogin()
// Re-title live windows on language flip. Wired internally so the binding generator
// doesn't try to expose the interface param.
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
@@ -136,7 +160,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
}()
}
- s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
+ return s
+}
+
+func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
+ w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
@@ -150,18 +178,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
URL: "/#/settings",
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
- Linux: LinuxAppearanceOptions(linuxIcon),
+ Linux: LinuxAppearanceOptions(s.linuxIcon),
})
- // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
- s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
- if ShuttingDown() {
- return
- }
- e.Cancel()
- s.app.Event.Emit(EventSettingsOpen, "general")
- s.settings.Hide()
+ w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
+ s.mu.Lock()
+ s.settings = nil
+ s.forgetWindowLocked(w)
+ s.mu.Unlock()
})
- return s
+ return w
}
// OpenSettings shows the settings window on tab (empty → General), switching tab via
@@ -171,11 +196,20 @@ func (s *WindowManager) OpenSettings(tab string) {
if target == "" {
target = "general"
}
- s.app.Event.Emit(EventSettingsOpen, target)
- s.settings.Show()
- s.settings.Focus()
- // Re-center (minimal-WM only; see centerWhenReady).
- s.centerWhenReady(s.settings)
+
+ w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
+
+ s.mu.Lock()
+ ready := s.ready[w.ID()]
+ if !ready {
+ s.pendingTab[w.ID()] = target
+ }
+ s.mu.Unlock()
+
+ if ready {
+ s.app.Event.Emit(EventSettingsOpen, target)
+ }
+ s.showWhenReady(w)
}
// OpenBrowserLogin shows the SSO popup, creating it on first use.
@@ -258,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() {
}
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
-// the countdown. Singleton, destroyed on close.
-func (s *WindowManager) OpenSessionExpiration(seconds int) {
+// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog
+// compares renewal snapshots against. Singleton, destroyed on close.
+func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
+ if deadlineUnixMilli > 0 {
+ startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
+ }
if s.sessionExpiration == nil {
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
opts.Screen = s.getScreenBasedOnCursorPosition()
@@ -440,13 +478,295 @@ func (s *WindowManager) OpenMain() {
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
func (s *WindowManager) ShowMain() {
- if s.mainWindow == nil {
+ s.showWhenReady(s.MainWindow())
+}
+
+// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready.
+func (s *WindowManager) ShowMainAndEmit(event string) {
+ w := s.MainWindow()
+ if w == nil {
return
}
- s.mainWindow.Show()
- s.mainWindow.Focus()
- // Re-center (minimal-WM only; see centerWhenReady).
- s.centerWhenReady(s.mainWindow)
+
+ id := w.ID()
+ s.mu.Lock()
+ ready := s.ready[id]
+ if !ready {
+ s.pendingEmits[id] = append(s.pendingEmits[id], event)
+ }
+ s.mu.Unlock()
+
+ s.showWhenReady(w)
+ if ready {
+ s.app.Event.Emit(event)
+ }
+}
+
+func (s *WindowManager) MainWindow() *application.WebviewWindow {
+ w, _ := s.ensureMain("/")
+ return w
+}
+
+func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) {
+ s.mu.Lock()
+ factory := s.newMain
+ s.mu.Unlock()
+ if factory == nil {
+ return s.ensureWindow(&s.mainWindow, nil)
+ }
+ return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow {
+ return factory(startURL)
+ })
+}
+
+func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) {
+ s.createMu.Lock()
+ defer s.createMu.Unlock()
+
+ s.mu.Lock()
+ w := *slot
+ s.mu.Unlock()
+ if w != nil || factory == nil {
+ return w, false
+ }
+
+ w = factory()
+ s.armReady(w)
+
+ s.mu.Lock()
+ *slot = w
+ s.mu.Unlock()
+ return w, true
+}
+
+func (s *WindowManager) armReady(w *application.WebviewWindow) {
+ if w == nil {
+ return
+ }
+ w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
+ timer := time.AfterFunc(paintedFallback, func() {
+ log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
+ s.markReady(w)
+ })
+ s.mu.Lock()
+ s.fallbackTimers[w.ID()] = timer
+ s.mu.Unlock()
+ })
+}
+
+func (s *WindowManager) watchPainted() {
+ s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
+ if w := s.windowByName(e.Sender); w != nil {
+ s.markReady(w)
+ }
+ })
+}
+
+func (s *WindowManager) watchTriggerLogin() {
+ s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) {
+ s.mu.Lock()
+ if s.headlessTimer != nil {
+ s.headlessTimer.Stop()
+ s.headlessTimer = nil
+ }
+ w := s.mainWindow
+ ready := w != nil && s.ready[w.ID()]
+ s.mu.Unlock()
+ if ready {
+ return
+ }
+
+ w, created := s.ensureMain("/")
+ if w == nil {
+ return
+ }
+
+ s.mu.Lock()
+ if created {
+ s.headlessMain = true
+ }
+ pending := !s.ready[w.ID()]
+ if pending {
+ s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
+ }
+ s.mu.Unlock()
+
+ if !pending {
+ s.app.Event.Emit(EventTriggerLogin)
+ }
+ })
+
+ s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) {
+ s.scheduleHeadlessTeardown()
+ })
+
+ s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) {
+ st, ok := e.Data.(Status)
+ if !ok {
+ return
+ }
+ switch st.Status {
+ case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
+ s.scheduleHeadlessTeardown()
+ }
+ })
+}
+
+func (s *WindowManager) scheduleHeadlessTeardown() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.headlessMain || s.mainWindow == nil {
+ return
+ }
+ if s.headlessTimer != nil {
+ s.headlessTimer.Stop()
+ }
+ s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain)
+}
+
+func (s *WindowManager) closeHeadlessMain() {
+ s.mu.Lock()
+ w := s.mainWindow
+ headless := s.headlessMain
+ s.headlessTimer = nil
+ s.mu.Unlock()
+ if !headless || w == nil {
+ return
+ }
+ w.Close()
+}
+
+func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
+ if w == nil {
+ return
+ }
+
+ id := w.ID()
+ if timer := s.fallbackTimers[id]; timer != nil {
+ timer.Stop()
+ }
+ delete(s.fallbackTimers, id)
+ delete(s.ready, id)
+ delete(s.showPending, id)
+ delete(s.pendingTab, id)
+ delete(s.pendingEmits, id)
+
+ kept := s.hiddenForLogin[:0]
+ for _, hidden := range s.hiddenForLogin {
+ if hidden != application.Window(w) {
+ kept = append(kept, hidden)
+ }
+ }
+ s.hiddenForLogin = kept
+}
+
+func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ switch name {
+ case "main":
+ return s.mainWindow
+ case "settings":
+ return s.settings
+ default:
+ return nil
+ }
+}
+
+func (s *WindowManager) markReady(w *application.WebviewWindow) {
+ id := w.ID()
+ s.mu.Lock()
+ already := s.ready[id]
+ s.ready[id] = true
+ wanted := s.showPending[id]
+ tab, hasTab := s.pendingTab[id]
+ emits := s.pendingEmits[id]
+ if timer := s.fallbackTimers[id]; timer != nil {
+ timer.Stop()
+ delete(s.fallbackTimers, id)
+ }
+ delete(s.showPending, id)
+ delete(s.pendingTab, id)
+ delete(s.pendingEmits, id)
+ s.mu.Unlock()
+
+ if already {
+ return
+ }
+
+ if hasTab {
+ s.app.Event.Emit(EventSettingsOpen, tab)
+ }
+
+ if wanted {
+ s.showNow(w)
+ }
+
+ for _, event := range emits {
+ s.app.Event.Emit(event)
+ }
+}
+
+func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
+ if w == nil {
+ return
+ }
+
+ id := w.ID()
+ s.mu.Lock()
+ ready := s.ready[id]
+ if !ready {
+ s.showPending[id] = true
+ }
+ s.mu.Unlock()
+
+ if ready {
+ s.showNow(w)
+ }
+}
+
+func (s *WindowManager) showNow(w *application.WebviewWindow) {
+ s.mu.Lock()
+ if w == s.mainWindow {
+ s.headlessMain = false
+ if s.headlessTimer != nil {
+ s.headlessTimer.Stop()
+ s.headlessTimer = nil
+ }
+ }
+ s.mu.Unlock()
+ w.Show()
+ w.Focus()
+ s.centerWhenReady(w)
+}
+
+func (s *WindowManager) ShowMainAt(url string) {
+ w, created := s.ensureMain(url)
+ if w == nil {
+ return
+ }
+ if !created {
+ w.SetURL(url)
+ }
+ s.showWhenReady(w)
+}
+
+func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.newMain = f
+}
+
+func (s *WindowManager) ForgetMain() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.forgetWindowLocked(s.mainWindow)
+ s.mainWindow = nil
+ s.headlessMain = false
+ if s.headlessTimer != nil {
+ s.headlessTimer.Stop()
+ s.headlessTimer = nil
+ }
}
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).
diff --git a/client/ui/tray.go b/client/ui/tray.go
index 148dd50b3..c392a0b62 100644
--- a/client/ui/tray.go
+++ b/client/ui/tray.go
@@ -174,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
- t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
+ t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -241,9 +241,6 @@ func (t *Tray) ShowWindow() {
w.Focus()
return
}
- if t.window == nil {
- return
- }
// Route through WindowManager so the main window is centered on first
// show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in
// the top-left corner.
@@ -251,8 +248,49 @@ func (t *Tray) ShowWindow() {
t.svc.WindowManager.ShowMain()
return
}
- t.window.Show()
- t.window.Focus()
+ if w := t.mainWindow(); w != nil {
+ w.Show()
+ w.Focus()
+ }
+}
+
+func (t *Tray) mainWindow() *application.WebviewWindow {
+ if t.svc.WindowManager == nil {
+ return t.window
+ }
+ return t.svc.WindowManager.MainWindow()
+}
+
+func (t *Tray) showMainAt(url string) {
+ if t.svc.WindowManager != nil {
+ t.svc.WindowManager.ShowMainAt(url)
+ return
+ }
+ if w := t.mainWindow(); w != nil {
+ w.SetURL(url)
+ w.Show()
+ w.Focus()
+ }
+}
+
+func (t *Tray) showMain() {
+ if t.svc.WindowManager != nil {
+ t.svc.WindowManager.ShowMain()
+ return
+ }
+ if w := t.mainWindow(); w != nil {
+ w.Show()
+ w.Focus()
+ }
+}
+
+func (t *Tray) showMainAndEmit(event string) {
+ if t.svc.WindowManager != nil {
+ t.svc.WindowManager.ShowMainAndEmit(event)
+ return
+ }
+ t.showMain()
+ t.app.Event.Emit(event)
}
// applyLanguage re-renders every translated surface in the Localizer's current
@@ -479,7 +517,8 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
// NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they
// need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so
// the React startLogin() (which owns the BrowserLogin popup) drives it;
- // the hidden main webview is alive and subscribed, so only the popup shows.
+ // the WindowManager materialises a hidden main webview when none is live,
+ // so only the popup shows.
t.statusMu.Lock()
needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) ||
strings.EqualFold(t.lastStatus, services.StatusSessionExpired) ||
diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go
index 12da68a5c..f23b5d715 100644
--- a/client/ui/tray_events.go
+++ b/client/ui/tray_events.go
@@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
- t.openSessionExpiration()
+ deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt])
+ t.openSessionExpiration(deadline)
return
}
t.notifySessionWarning(
diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go
index f25419894..91c38be08 100644
--- a/client/ui/tray_session.go
+++ b/client/ui/tray_session.go
@@ -30,10 +30,7 @@ const (
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
- if t.window != nil {
- t.window.Show()
- t.window.Focus()
- }
+ t.showMain()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -287,12 +284,23 @@ func (t *Tray) dismissSessionWarning() {
}
// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
-// Idempotent on the WindowManager side.
-func (t *Tray) openSessionExpiration() {
+// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon,
+// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the
+// WindowManager side.
+func (t *Tray) openSessionExpiration(deadline time.Time) {
if t.svc.WindowManager == nil {
return
}
- t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
+ if deadline.IsZero() {
+ t.sessionMu.Lock()
+ deadline = t.sessionExpiresAt
+ t.sessionMu.Unlock()
+ }
+ var deadlineMs int64
+ if !deadline.IsZero() {
+ deadlineMs = deadline.UnixMilli()
+ }
+ t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs)
}
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
@@ -307,11 +315,11 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
- t.app.Event.Emit(services.EventTriggerLogin)
+ t.showMainAndEmit(services.EventTriggerLogin)
return
}
if t.svc.WindowManager == nil {
return
}
- t.svc.WindowManager.OpenSessionExpiration(seconds)
+ t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli())
}
diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go
index 27037eccb..3ce1f9600 100644
--- a/client/ui/tray_update.go
+++ b/client/ui/tray_update.go
@@ -4,6 +4,7 @@ package main
import (
"context"
+ neturl "net/url"
"sync"
"time"
@@ -19,7 +20,7 @@ import (
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
type trayUpdater struct {
app *application.App
- window *application.WebviewWindow
+ showMainAt func(url string)
update *services.Update
notifier *Notifier
loc *Localizer
@@ -36,10 +37,10 @@ type trayUpdater struct {
progressWindowOpen bool
}
-func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
+func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
- window: window,
+ showMainAt: showMainAt,
update: update,
notifier: notifier,
loc: loc,
@@ -185,14 +186,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
// openProgressWindow points the main window at the /update progress page and
// brings it forward.
func (u *trayUpdater) openProgressWindow(version string) {
- if u.window == nil {
+ if u.showMainAt == nil {
return
}
url := "/#/update"
if version != "" {
- url += "?version=" + version
+ url += "?version=" + neturl.QueryEscape(version)
}
- u.window.SetURL(url)
- u.window.Show()
- u.window.Focus()
+ u.showMainAt(url)
}
diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go
index 4683f4033..260a528f0 100644
--- a/client/wasm/cmd/main.go
+++ b/client/wasm/cmd/main.go
@@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error {
// parseClientOptions extracts NetBird options from JavaScript object
func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options := netbird.Options{
- DeviceName: "dashboard-client",
- LogLevel: defaultLogLevel,
+ LogLevel: defaultLogLevel,
}
if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() {
@@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options.DeviceName = deviceName.String()
}
- if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() {
- options.DisableIPv6 = disableIPv6.Bool()
+ disableIPv6, err := boolOption(jsOptions, "disableIPv6")
+ if err != nil {
+ return options, err
+ }
+ if disableIPv6 != nil {
+ options.DisableIPv6 = *disableIPv6
}
+ // The caller decides whether this client uses lazy connections; left unset it
+ // defers to the management feature flag. A short-lived, interactive caller
+ // turns it off so its sessions reach the few peers their grant covers eagerly,
+ // instead of the first request waiting for the connection to be established.
+ lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled")
+ if err != nil {
+ return options, err
+ }
+ options.LazyConnectionEnabled = lazyConnectionEnabled
+
return options, nil
}
+// boolOption reads a boolean option, returning nil when the caller left it out.
+// js.Value.Bool panics on any other type, so a wrong type is reported instead.
+func boolOption(jsOptions js.Value, name string) (*bool, error) {
+ v := jsOptions.Get(name)
+ if v.IsNull() || v.IsUndefined() {
+ return nil, nil
+ }
+ if v.Type() != js.TypeBoolean {
+ return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type())
+ }
+ b := v.Bool()
+ return &b, nil
+}
+
// createStartMethod creates the start method for the client
func createStartMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) any {
diff --git a/client/wasm/cmd/main_test.go b/client/wasm/cmd/main_test.go
new file mode 100644
index 000000000..3ec5a8f6a
--- /dev/null
+++ b/client/wasm/cmd/main_test.go
@@ -0,0 +1,64 @@
+//go:build js
+
+package main
+
+import (
+ "syscall/js"
+ "testing"
+)
+
+// TestParseClientOptionsBooleans covers the boolean options against the value
+// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean,
+// so a wrong type has to be rejected before it reaches the client.
+func TestParseClientOptionsBooleans(t *testing.T) {
+ t.Run("unset leaves the lazy override empty", func(t *testing.T) {
+ options, err := parseClientOptions(js.Global().Get("Object").New())
+ if err != nil {
+ t.Fatalf("parse options: %v", err)
+ }
+ if options.LazyConnectionEnabled != nil {
+ t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
+ }
+ if options.DisableIPv6 {
+ t.Error("disableIPv6 should default to false")
+ }
+ })
+
+ t.Run("null defers to the management flag", func(t *testing.T) {
+ jsOptions := js.Global().Get("Object").New()
+ jsOptions.Set("lazyConnectionEnabled", js.Null())
+ options, err := parseClientOptions(jsOptions)
+ if err != nil {
+ t.Fatalf("parse options: %v", err)
+ }
+ if options.LazyConnectionEnabled != nil {
+ t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
+ }
+ })
+
+ t.Run("booleans are carried through", func(t *testing.T) {
+ jsOptions := js.Global().Get("Object").New()
+ jsOptions.Set("lazyConnectionEnabled", false)
+ jsOptions.Set("disableIPv6", true)
+ options, err := parseClientOptions(jsOptions)
+ if err != nil {
+ t.Fatalf("parse options: %v", err)
+ }
+ if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled {
+ t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled)
+ }
+ if !options.DisableIPv6 {
+ t.Error("disableIPv6 should be true")
+ }
+ })
+
+ t.Run("a non-boolean is rejected", func(t *testing.T) {
+ for _, value := range []any{"true", 1, js.Global().Get("Object").New()} {
+ jsOptions := js.Global().Get("Object").New()
+ jsOptions.Set("lazyConnectionEnabled", value)
+ if _, err := parseClientOptions(jsOptions); err == nil {
+ t.Errorf("value %v should be rejected", value)
+ }
+ }
+ })
+}
diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage
index 79746819d..011379c2f 100644
--- a/combined/Dockerfile.multistage
+++ b/combined/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
WORKDIR /app
# Install build dependencies
diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist
index fe10b5b63..eec96d35b 100644
--- a/docs/io.netbird.client.plist
+++ b/docs/io.netbird.client.plist
@@ -85,6 +85,21 @@
-->
+
+
+
+
+
+