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/connection.go b/client/ui/services/connection.go
index aa649bb6d..f78ce4c0f 100644
--- a/client/ui/services/connection.go
+++ b/client/ui/services/connection.go
@@ -123,8 +123,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
if p.PreSharedKey != "" {
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
}
- if p.Hint != "" {
- req.Hint = ptrStr(p.Hint)
+ hint := p.Hint
+ if hint == "" && profileID != "" {
+ if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil {
+ hint = state.Email
+ } else {
+ log.Debugf("failed to get profile state for login hint: %v", serr)
+ }
+ }
+ if hint != "" {
+ req.Hint = ptrStr(hint)
}
resp, err := cli.Login(ctx, req)
@@ -228,16 +236,6 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
return s.classifyDaemonError(err)
}
- // The daemon runs as root and can't reach the user-owned per-profile state
- // file holding the account email (see Profiles.List), so clear the stale
- // email here; the next SSO login recreates it.
- if p.ProfileName != "" {
- if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
- // Non-fatal: the logout itself succeeded.
- log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
- }
- }
-
return nil
}
@@ -261,7 +259,7 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
// Persist the account email the same way the CLI does after its own
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
- // root and the per-profile state file is user-owned (see Logout below).
+ // root and the per-profile state file is user-owned (see Profiles.List).
// Without this the profile has no email, so Profiles.List shows no account
// and later logins and session extends go out without a login_hint —
// leaving the IdP to guess which account was meant.
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/profile.go b/client/ui/services/profile.go
index 5a9a0e68d..e76ab3db6 100644
--- a/client/ui/services/profile.go
+++ b/client/ui/services/profile.go
@@ -162,8 +162,9 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
}
// The daemon deletes what it owns but runs as root, so it leaves the
- // user-owned state file holding the account email behind (same split as
- // Connection.Logout). Legacy profiles are keyed by name rather than by a
+ // user-owned state file holding the account email behind. Logout keeps the
+ // email on purpose so later logins can pass it as the login_hint; profile
+ // removal is what deletes it. Legacy profiles are keyed by name rather than by a
// generated ID, so a recreated profile of the same name would inherit the
// deleted one's email and offer it as the login_hint.
//
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/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go
index 9cfe65266..28ae95ec0 100644
--- a/client/wasm/internal/ssh/client.go
+++ b/client/wasm/internal/ssh/client.go
@@ -80,13 +80,12 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer
return fmt.Errorf("dial %s: %w", addr, err)
}
- sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
+ sshClient, err := nbssh.Handshake(ctx, conn, addr, config)
if err != nil {
- closeWithLog(conn, "connection after handshake error")
- return fmt.Errorf("SSH handshake: %w", err)
+ return err
}
- c.sshClient = ssh.NewClient(sshConn, chans, reqs)
+ c.sshClient = sshClient
logrus.Infof("SSH: Connected to %s", addr)
return nil
@@ -119,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) {
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
-// StartSession starts an SSH session with PTY
+// StartSession starts an SSH session with PTY. It holds the client lock for
+// the whole startup so Close cannot tear the client down mid-setup and the
+// new session cannot be installed into an already closed client.
func (c *Client) StartSession(cols, rows int) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
if c.sshClient == nil {
return fmt.Errorf("SSH client not connected")
}
- session, err := c.sshClient.NewSession()
+ pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows)
if err != nil {
- return fmt.Errorf("create session: %w", err)
+ return err
}
- c.mu.Lock()
- defer c.mu.Unlock()
- c.session = session
-
- modes := ssh.TerminalModes{
- ssh.ECHO: 1,
- ssh.TTY_OP_ISPEED: 14400,
- ssh.TTY_OP_OSPEED: 14400,
- ssh.VINTR: 3,
- ssh.VQUIT: 28,
- ssh.VERASE: 127,
- }
-
- if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
- closeWithLog(session, "session after PTY error")
- return fmt.Errorf("PTY request: %w", err)
- }
-
- c.stdin, err = session.StdinPipe()
- if err != nil {
- closeWithLog(session, "session after stdin error")
- return fmt.Errorf("get stdin: %w", err)
- }
-
- c.stdout, err = session.StdoutPipe()
- if err != nil {
- closeWithLog(session, "session after stdout error")
- return fmt.Errorf("get stdout: %w", err)
- }
-
- c.stderr, err = session.StderrPipe()
- if err != nil {
- closeWithLog(session, "session after stderr error")
- return fmt.Errorf("get stderr: %w", err)
- }
-
- if err := session.Shell(); err != nil {
- closeWithLog(session, "session after shell error")
- return fmt.Errorf("start shell: %w", err)
- }
+ c.session = pty.Session
+ c.stdin = pty.Stdin
+ c.stdout = pty.Stdout
+ c.stderr = pty.Stderr
logrus.Info("SSH: Session started with PTY")
return nil
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/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md
index b8891001b..0de6b4c33 100644
--- a/docs/agent-networks/01-end-to-end-flows.md
+++ b/docs/agent-networks/01-end-to-end-flows.md
@@ -115,7 +115,7 @@ sequenceDiagram
Resp->>Resp: parse usage tokens, completion
Note over Resp: capture_completion gates raw
completion capture
Resp->>Cost: tokens
- Cost->>Cost: lookup pricing.yaml + compute cost
+ Cost->>Cost: lookup rates from config-delivered
pricing table + compute cost
Cost->>Rec: tokens + cost
Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user)
Rec-->>Log: emit access-log entry
(if EnableLogCollection)
diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md
index cc74206e9..f91c369f7 100644
--- a/docs/agent-networks/modules/21-management-agentnetwork.md
+++ b/docs/agent-networks/modules/21-management-agentnetwork.md
@@ -15,6 +15,10 @@ Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthe
| ---- | ---- |
| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger |
| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain |
+| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config |
+| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` |
+| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) |
+| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) |
| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) |
| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete |
| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) |
@@ -48,6 +52,8 @@ flowchart TD
I --> J[indexProviderGroups: providerID -> sorted source groups]
J --> K[buildRouterConfigJSON drops orphan providers]
J --> L[buildIdentityInjectConfigJSON per catalog entry]
+ J --> K2[buildCostMeterConfigJSON: default table + per-provider prices]
+ K2 --> P
H --> M[mergeGuardrails: union allowlist, OR redact]
M --> N[applyAccountCollectionControls account toggle = SOLE capture control]
N --> O[marshalGuardrailConfig]
@@ -60,6 +66,84 @@ flowchart TD
R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map]
```
+### LLM pricing (management is the sole authority)
+
+**The proxy carries no price list.** Management synthesizes the entire pricing
+table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches
+the proxies as an ordinary mapping push — the chain rebuild installs a fresh
+table and there is nothing to reload on the proxy side.
+
+```mermaid
+flowchart TD
+ A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults]
+ B --> C{AgentNetwork.PricingDefaultsFile}
+ C -- absent --> D[compiled-in table serves]
+ C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base]
+ E --> F[mergedTable atomic.Pointer]
+ D --> G[DefaultTable]
+ F --> G
+ G --> H[buildCostMeterConfigJSON — pricing.defaults]
+ I[types.Provider.Models operator prices] --> J[normalizePricingModelID
bedrock ARN/region/version, vertex @version]
+ J --> K[materializeEntry: default entry as base,
operator input/output verbatim,
cache pointers only when non-nil]
+ K --> L[pricing.providers keyed by provider record ID]
+ H --> M[cost_meter ConfigJSON]
+ L --> M
+ G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows]
+ O[StartReloader: mtime poll every ReloadInterval 1m] --> E
+```
+
+**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`):
+
+- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model
+ id → rates. The **full** default table ships to every account: it is small
+ (~10 KB) and it is what keeps gateway-style providers (which enumerate no
+ models, so they claim every model) priced.
+- `pricing.providers` — provider **record** id → normalized model id → rates,
+ matched against the `llm.resolved_provider_id` the router stamps. Entries are
+ **fully materialized here**, at synth time: `materializeEntry` starts from the
+ default entry for that model so cache rates the operator didn't state are
+ inherited, overlays operator `input`/`output` verbatim (**including an explicit
+ 0**, which prices a self-hosted or internal endpoint as free rather than
+ silently reverting to list price), and overlays cache-rate **pointers only when
+ non-nil** — `nil` means "inherit the default", an explicit `0` means "no
+ discount, bill this bucket at the input rate". The proxy therefore does two map
+ lookups and no merging.
+
+Same orphan rule as the router: a provider no enabled policy authorises is
+unreachable, so its prices aren't shipped. Model ids are normalized with the
+**same** functions the request parser uses (`NormalizeBedrockModel` /
+`NormalizeVertexModel`), which is what makes the per-record lookup key compare
+equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve
+first-occurrence-wins, matching the routing dedup order.
+
+**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator
+replace default rates without a rebuild. Schema is `surface → model → rates`
+(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` /
+`cache_read_per_1k` / `cache_creation_per_1k`). Semantics:
+
+- A **relative** path resolves against ``, so a bare filename lands
+ alongside the store. Empty config probes `/defaults_llm_pricing.yaml`.
+- An **explicitly configured** path is *required to load*: a typo or malformed
+ file fails startup, because the operator believes those rates are live. The
+ conventional probe is optional — an absent file just serves compiled-in
+ defaults, and the path stays watched in case it appears later.
+- File entries **replace** the compiled-in entry for the same (surface, model)
+ **whole** — they are not field-merged, so an entry must repeat the cache rates
+ it wants to keep. Everything the file doesn't mention keeps built-in rates.
+- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be
+ finite and non-negative — the same constraints the HTTP API enforces on
+ operator per-provider prices.
+- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**:
+ a parse error keeps the previous table, a deleted file reverts to compiled-in
+ defaults. A mid-edit save can never take pricing down.
+
+The live table feeds **both** consumers, which is what keeps them consistent: the
+synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog`
+via `applyDefaultPricing` (what the dashboard's model-row prices prefill with).
+`defaults_llm_pricing.example.yaml` is generated from the compiled-in table
+(`go generate ./management/internals/modules/agentnetwork/pricing`) and
+golden-tested, so operators start from a file matching the built-in rates exactly.
+
### Budget rule resolution (min-wins, group+user bound)
```mermaid
@@ -124,7 +208,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
| on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** |
| on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – |
| on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – |
- | on_response | 6 | `cost_meter` | `{}` | – |
+ | on_response | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – |
| on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – |
- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=.`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`.
@@ -139,6 +223,12 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry.
- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`).
- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced.
+- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management".
+- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers.
+- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`.
+- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults.
+- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates.
+- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`).
## Things to scrutinize
@@ -176,10 +266,12 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value.
- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating.
- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out.
+- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0.
### Performance
- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions.
+- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build.
- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant.
- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect.
@@ -188,6 +280,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume).
- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`).
- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden.
+- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`).
## Test coverage
@@ -198,6 +291,9 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. |
| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. |
| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. |
+| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. |
+| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. |
+| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. |
| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. |
| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. |
| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. |
diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md
index efe1bc4ce..ad56feb77 100644
--- a/docs/agent-networks/modules/31-proxy-middleware-builtin.md
+++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md
@@ -5,7 +5,7 @@ LLM request. The two highest-blast-radius areas are the **capture-pointer
semantics** and the **limit_check ⇒ limit_record** record-once invariant.
Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK
-adapters + pricing catalog this chain delegates to.
+adapters + pricing table and cost formula this chain delegates to.
---
@@ -34,7 +34,7 @@ rewrites.
| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite |
| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) |
| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none |
-| `cost_meter` | OnResponse | `llm.{provider,model}`, token buckets | `cost.usd_total` or `cost.skipped` | pricing lookup |
+| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) |
| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` |
[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go)
@@ -44,7 +44,7 @@ locks the ID set; adding or removing one is a conscious extension.
| File | LOC | Notes |
|---|---:|---|
-| `builtin.go` | 86 | Registry + `FactoryContext` (ctx, data dir, meter, logger, mgmt client) |
+| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) |
| `all_test.go` | 41 | Locks the 8-ID registry surface |
| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path |
| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating |
@@ -53,7 +53,7 @@ locks the ID set; adding or removing one is a conscious extension.
| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders |
| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction |
| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit |
-| `cost_meter/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` |
+| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config |
| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) |
## Per-middleware
@@ -168,12 +168,46 @@ token schema.
### cost_meter
-Reads `llm.provider` + `llm.model` + token buckets, looks up per-1k rate via
-`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped`
-reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
-`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime
-context via `startReloader`. **Key invariant:** provider-shape switch lives
-in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic.
+Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates,
+and emits the full `cost.usd_*` breakdown (four per-bucket values plus the
+`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason
+(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
+`unknown_model`).
+
+**Management owns pricing.** The proxy carries no embedded price list: the whole
+table arrives in this middleware's `ConfigJSON` as
+`{pricing: {defaults, providers}}`, synthesized by management from the catalog
+plus the operator's stored per-provider prices
+([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
+Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at
+construction, so a non-finite or negative rate fails the chain build. A price
+change is an ordinary mapping push — the chain rebuild yields a fresh instance
+over a fresh immutable table, so there is no data dir, no pricing file, no
+reload goroutine, and nothing to invalidate.
+
+**Two-tier lookup**
+([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)):
+
+1. **Per-provider-record** — the operator's stored price for the route that
+ actually served the request, keyed by the `llm.resolved_provider_id` that
+ `llm_router` stamped on the allow path, then by normalized model id. Entries
+ arrive fully materialized (management folds default cache rates in at synth
+ time), so there is no merging here. Absent metadata — no router in the chain
+ — skips this tier.
+2. **Surface defaults** — the catalog-derived table keyed by `llm.provider`
+ (`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style
+ providers, which enumerate no models and therefore get no per-record entry.
+
+**Backward compatibility:** a config with no `pricing` block means management
+predates config-delivered pricing. The factory logs one warning at build time
+and the instance records `cost.skipped=unknown_model` ($0) for every request
+rather than falling back to a stale built-in price list
+([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
+
+**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts`
+(sibling doc) and is selected by the **surface**, not by which tier the entry
+came from — `cost_meter` stays provider-agnostic, and a per-record override on
+an Anthropic route still bills its cache buckets additively.
### llm_limit_record
@@ -246,12 +280,14 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter`
| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` |
| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) |
| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` |
-| `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) |
+| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` |
| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` |
All factories accept empty / null / `{}` / whitespace as zero-value config;
only structurally invalid JSON is rejected so misconfig surfaces at chain
-build time.
+build time. `cost_meter` adds a semantic check on top of that: a `pricing`
+block carrying a negative or non-finite rate fails the build too, rather than
+mispricing live traffic.
## Invariants
@@ -320,10 +356,11 @@ non-object `metadata` field
— header path still attributes, but body-level tag-budget enforcement
doesn't run for that request.
-**Concurrency.** `cost_meter` shares a `pricing.Loader` via
-`atomic.Pointer[Table]`; readers always see a consistent table. Every
-middleware is a stateless value receiver. Integration test uses real bufconn
-gRPC — race detector is the meaningful bar.
+**Concurrency.** `cost_meter`'s two pricing tables are built once from the
+middleware config and never mutated, so the lookup path needs no lock or atomic
+swap — a price change replaces the whole instance. Every middleware is
+otherwise a stateless value receiver. Integration test uses real bufconn gRPC —
+race detector is the meaningful bar.
**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost`
is O(1); SSE accumulation is single-pass. No map allocation per call.
@@ -349,13 +386,13 @@ counter accuracy.
| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven |
| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation |
| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort |
-| `cost_meter/middleware_test.go` | 17 | Each skip reason, provider-shape, pricing loader integration |
+| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection |
| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed |
## Cross-references
- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters
- + SSE framer + pricing loader.
+ + SSE framer + pricing table and cost formula.
- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP
token minting, `/bedrock` prefix:
[50-path-routed-providers.md](./50-path-routed-providers.md).
diff --git a/docs/agent-networks/modules/32-proxy-llm-parsers.md b/docs/agent-networks/modules/32-proxy-llm-parsers.md
index 0376bc988..52faeaac1 100644
--- a/docs/agent-networks/modules/32-proxy-llm-parsers.md
+++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md
@@ -9,7 +9,7 @@ pricing table's per-provider cost formula is the highest-leverage place a
small bug would silently mis-bill operators.
Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
-— the 8 middlewares that consume this package's parsers + pricing loader.
+— the 8 middlewares that consume this package's parsers + pricing table.
---
@@ -24,8 +24,9 @@ proxy-framework dependencies:
- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls.
- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`).
- `errors.go` — sentinels callers branch on with `errors.Is`.
-- `pricing/` — embedded-default + hot-reload override table with
- symlink-safe Unix loader (build-tagged stub elsewhere).
+- `pricing/` — immutable pricing table + the per-surface cost formula. The
+ rates themselves come from management inside `cost_meter`'s middleware
+ config; this package holds no price list and reads no files.
- `fixtures/` — captured request/response/stream bodies the tests replay.
The package carries zero proxy-framework dependencies so the same parsers can
@@ -47,12 +48,9 @@ be reused later by a WASM adapter
| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits |
| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values |
| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` |
-| `pricing/pricing.go` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload |
-| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap |
-| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" |
-| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize |
-| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth |
-| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter |
+| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates |
+| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation |
+| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream |
## Request body → parser dispatch
@@ -188,9 +186,11 @@ response leg, covering both Bedrock body shapes:
`totalTokens`). `firstNonZero` folds the two naming conventions into one
`Usage`; when Converse omits `totalTokens` the parser sums the buckets.
-`ProviderName()` returns `"bedrock"` — its own `defaults_pricing.yaml` block,
-keyed by the **normalised** model id (region prefix + version suffix stripped by
-the request parser). `ParseResponse` returns `ErrStreamingUnsupported` for an
+`ProviderName()` returns `"bedrock"` — its own pricing surface in the table
+management ships, keyed by the **normalised** model id (region prefix + version
+suffix stripped by the request parser; management normalises its keys the same
+way at synth time so the two compare equal). `ParseResponse` returns
+`ErrStreamingUnsupported` for an
AWS binary event-stream content-type (`application/vnd.amazon.eventstream`,
`isAWSEventStream`) so the caller routes to the streaming accumulator instead.
@@ -205,11 +205,34 @@ response body. Streaming accumulators live in the middleware package
([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go))
but use `llm.NewScanner` so the framing contract stays here.
-### Pricing catalog
+### Pricing table
-`Table.Cost`
-([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go))
-is the cost formula — most security-relevant math in this module:
+**Management is the sole pricing authority.** The proxy carries no embedded
+price list and reads no pricing file: the whole table arrives inside
+`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change
+is just another push — the chain rebuild constructs a fresh `Table`, so there
+is nothing to reload
+([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The
+management side of the contract (catalog defaults, the operator's stored
+per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the
+management-side module guide; `cost_meter`'s wire shape is in
+[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md).
+
+`EntryJSON`
+([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the
+management→proxy contract — five USD-per-1k rates under `input_per_1k`,
+`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`,
+`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical
+names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by
+direct struct conversion rather than field-by-field copying (a new rate can't
+be silently dropped in transit).
+
+`EntryCosts`
+([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go))
+is the cost formula — most security-relevant math in this module. The
+**surface** (the `llm.provider` value the request parser stamped) selects the
+formula, never the tier the entry came from: a per-provider-record override on
+an Anthropic route still bills its cache buckets additively.
| Provider | Formula |
|---|---|
@@ -218,7 +241,7 @@ is the cost formula — most security-relevant math in this module:
| default | `inTokens × InputPer1K + outTokens × OutputPer1K` |
`bedrock` shares the Anthropic additive-cache formula
-([pricing.go:172-174](../../../proxy/internal/llm/pricing/pricing.go)):
+([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)):
Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic
Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces
to `input + output`.
@@ -226,15 +249,12 @@ to `input + output`.
Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in
to discounts by setting the field.
-`Loader`
-([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go))
-overlays an optional `pricing.yaml` from data-dir on top of the go:embed
-defaults. Atomic pointer swap means readers never observe a partial update.
-The mtime-poll reloader (30s default cadence) keeps the previous table on
-parse failure so cost annotation never goes blank during a botched edit.
-
-`defaults_pricing.yaml` is the source of truth for built-in pricing.
-Operator overrides only carry the entries they want to change.
+`Costs`
+([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the
+per-request split. The four per-bucket fields are the base; `TotalUSD` and
+`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from
+the breakdown. `InputUSD` is always the non-cached input bucket on both
+provider shapes, so input and cached-input never double-count.
## Public contracts
@@ -264,29 +284,38 @@ Order matters: `DetectFromURL` ties resolve by registration order.
`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat
them as wire-stable — new providers must take fresh numbers.
-**`Pricing` lookup**
-([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)):
+**`Pricing` construction + lookup**
+([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)):
```go
+func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error)
+func NewTable(raw map[string]map[string]EntryJSON) (*Table, error)
+
+func (t *Table) Lookup(provider, model string) (Entry, bool)
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool)
+func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool)
+func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs
```
-Nil-safe: `t.Cost` on a nil receiver returns `(0, false)`
-([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)).
-`ok=false` means provider or model is absent from the loaded table; the caller
-emits `cost.skipped=unknown_model`.
+`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw
+two-level map `cost_meter` uses for the per-provider-record tier (it looks up an
+`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both
+reject any non-finite or negative rate, so a corrupt config fails the chain
+build rather than mispricing silently. Nil input yields an empty,
+never-matching table.
+
+Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false`
+([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)).
+`ok=false` means the surface or model is absent from the table management sent;
+the caller emits `cost.skipped=unknown_model`.
## Invariants
-1. **Cross-platform pricing build.** `pricing_unix.go` carries the only
- functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an
- open descriptor — both Unix-only). `pricing_other.go` is a build-tag
- fallback that returns `"not supported on this platform"`
- ([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)).
- The proxy is Linux-only in production today; a Windows port needs an
- equivalent path-as-handle implementation. Reviewers building on Windows
- should expect this surface to return an error at startup if an override
- file is configured.
+1. **The pricing package is pure and platform-independent.** No file I/O, no
+ `//go:embed`, no goroutines, no build tags — the rates arrive as config, so
+ there is nothing platform-specific left to port. Anything reintroducing a
+ read-from-disk path here re-splits pricing authority between management and
+ the proxy, which is exactly what this design removed.
2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end
in `\n\n` still yields its accumulated event before `io.EOF`
@@ -298,38 +327,45 @@ emits `cost.skipped=unknown_model`.
usage rather than aborting
([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)).
-3. **`defaults_pricing.yaml` is the source of truth.** Compiled into the
- binary via `//go:embed`
- ([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)).
- `DefaultTable()` parses once and panics on parse failure
- ([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go))
- — by design: a broken embedded YAML must not ship to production.
+3. **Management is the only source of rates.** `Table` has no constructor that
+ invents prices: the only way in is `NewTable`/`NewEntries` over the wire map
+ management sent. A missing or empty `pricing` block therefore means *no
+ prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) —
+ never a stale built-in fallback that would silently bill list price.
-4. **Loader path validation.** `resolveMiddlewareDataPath`
- ([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go))
- rejects absolute paths, traversal segments, and basenames that fail
- `basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain
- inside `baseDir` even after `filepath.Clean`. Tests:
- `TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`,
- `TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`.
+4. **Tables are immutable once built.** `Table.entries` is written only in
+ `NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord`
+ map is likewise build-time-only
+ ([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This
+ is what makes the no-reload design safe: a price change arrives as a mapping
+ push that builds a new middleware instance over a new table, so concurrent
+ readers can't observe a half-updated price list and no atomic swap or lock
+ is needed on the hot path.
-5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the
- open descriptor (never re-stat by path), `info.Mode().IsRegular()` check,
- `io.LimitReader(f, maxPricingBytes+1)` with a final size assertion
- ([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)).
- A mid-read symlink swap is detected because the fstat is on the original
- fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`.
+5. **Rate validation happens at chain-build time, not per request.**
+ `NewEntries` rejects negative, NaN, and ±Inf rates field by field
+ ([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming
+ the offending surface/model/field in the error. Management enforces the same
+ constraints at its API boundary and in its YAML parser, so this is
+ defense-in-depth — but it means a corrupt push fails loudly at build instead
+ of producing negative costs on live traffic. Test:
+ `TestNewTable_ValidatesRates`.
-6. **`yaml.NewDecoder(...).KnownFields(true)`**
- ([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go))
- rejects YAML files that carry fields not in the schema. A typo in an
- operator override file fails loud instead of silently zeroing rates.
+6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's
+ `pricing.Entry` together.** `NewEntries` converts by direct struct
+ conversion `Entry(e)`
+ ([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which
+ only compiles while the two structs stay field-identical — so the proxy half
+ is compiler-enforced. The management half is not: a rate added there but not
+ here unmarshals into nothing and prices that bucket at `InputPer1K`.
## Things to scrutinise
-**Correctness.** Verify OpenAI cached-prompt clamp at
-[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go)
-short-circuits before subtraction. `Anthropic.TotalTokens` sums all four
+**Correctness.** Verify the OpenAI cached-prompt clamp at
+[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go)
+short-circuits before subtraction. Negative token counts are clamped to zero up
+front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so
+no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four
buckets (in + out + cache_read + cache_creation) — downstream dashboards
need to know this differs from `input + output`.
`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a
@@ -338,22 +374,27 @@ noting).
**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event
errors from `Scanner.Next` and both accumulators stop with partial usage.
-Pricing file 1 MiB cap is orders of magnitude larger than realistic. Confirm
-new schema additions are mirrored in both `pricingFile` and `Entry`;
-`KnownFields(true)` will reject silently-typo'd operator overrides
-otherwise.
+Pricing is no longer file-backed, so the loader's path-traversal / symlink /
+oversize surface is gone entirely — the config channel (an authenticated
+mapping push from management) is now the only way rates enter the proxy, and
+`NewEntries` is the validation boundary on it. A new rate added to management's
+`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing
+path (see invariant 6).
-**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never
-block or see a torn table. `Loader.Reload` is one goroutine, cancelled via
-context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()`
-uses `sync.Once`. Per-call `Scanner` instances mean no shared state across
-concurrent response-parser calls.
+**Concurrency.** Nothing in this package is shared mutable state: tables are
+built once and never written again, so `cost_meter`'s hot path is lock-free by
+construction rather than by atomic swap. Per-call `Scanner` instances mean no
+shared state across concurrent response-parser calls.
-**Perf.** `Table.Cost` is two map lookups + multiplications, O(1).
-`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s.
+**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the
+per-provider-record tier adds at most one more lookup. `Scanner.Next` is one
+`ReadString('\n')` per line. No background goroutines and no per-request
+allocation of pricing state.
-**Observability.** Reload failures count via `metric.Int64Counter` keyed
-`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood.
+**Observability.** A config carrying no `pricing` block logs one warning at
+chain-build time (`cost_meter` factory) and then records
+`cost.skipped=unknown_model` per request, so an old-management deployment is
+visible in both logs and the access log rather than quietly reporting $0.
Parser errors return sentinels — middleware uses `errors.Is` to map to the
right `cost.skipped` reason.
@@ -365,7 +406,7 @@ right `cost.skipped` reason.
| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays |
| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays |
| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection |
-| `pricing/pricing_test.go` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation |
+| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table |
**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)):
`openai_chat_completion.json` (chat.completions with usage),
@@ -373,14 +414,15 @@ right `cost.skipped` reason.
`openai_stream.txt` (3 deltas + usage + `[DONE]`),
`anthropic_messages.json` (Messages API non-streaming),
`anthropic_stream.txt` (full 7-event sequence: message_start →
-content_block_{start,delta×2,stop} → message_delta (usage) → message_stop),
-`pricing.yaml` (realistic-pricing starter for operator overrides).
+content_block_{start,delta×2,stop} → message_delta (usage) → message_stop).
+No pricing fixture: the table is config-delivered, so pricing tests construct
+it in-process from a wire-shape map.
## Cross-references
- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
— the chain that calls `llm.Parsers()`, `llm.ParserByName`,
- `llm.NewScanner`, `pricing.NewLoader`.
+ `llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`.
- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the
Bedrock AWS event-stream accumulator:
[50-path-routed-providers.md](./50-path-routed-providers.md).
diff --git a/docs/agent-networks/modules/33-proxy-runtime.md b/docs/agent-networks/modules/33-proxy-runtime.md
index f553473f8..54046b614 100644
--- a/docs/agent-networks/modules/33-proxy-runtime.md
+++ b/docs/agent-networks/modules/33-proxy-runtime.md
@@ -1,7 +1,7 @@
# proxy/runtime — translate + serve + log
> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target.
-> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path.
+> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config.
## Module boundary
@@ -114,8 +114,7 @@ At **request time** the access-log middleware stamps `CapturedData`; the auth ch
## Public contracts touched
-- `proxy.Server.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241).
-- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250).
+- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client.
- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56).
- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default.
- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258).
diff --git a/docs/agent-networks/modules/50-path-routed-providers.md b/docs/agent-networks/modules/50-path-routed-providers.md
index b7cda3a97..08c976c5f 100644
--- a/docs/agent-networks/modules/50-path-routed-providers.md
+++ b/docs/agent-networks/modules/50-path-routed-providers.md
@@ -87,9 +87,9 @@ strips the `@version` suffix from the model, and maps the publisher to a parser
surface via `vertexPublisherVendor`:
- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic
- parser, priced under the **`anthropic`** block in `defaults_pricing.yaml`
- (the parser emits the standard Anthropic provider label, so Vertex Claude
- reuses first-party Anthropic prices).
+ parser, priced under the **`anthropic`** surface of the pricing table
+ management ships (the parser emits the standard Anthropic provider label, so
+ Vertex Claude reuses first-party Anthropic prices).
- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup
today).
- anything else (notably `google` / Gemini) → empty vendor → **no parser**.
@@ -104,8 +104,9 @@ is omitted from the catalog.
> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price
> premium that the base per-token rates do **not** model — cost annotations for
-> those regions read low. Operators who need exact regional billing override
-> the affected entries in `pricing.yaml`.
+> those regions read low. Operators who need exact regional billing set the
+> affected models' prices on the provider record, or replace the default entries
+> via management's `AgentNetwork.PricingDefaultsFile`.
## AWS Bedrock (`bedrock_api`)
@@ -211,15 +212,19 @@ so a model-listing call can't be rewritten onto an upstream that would 404 it.
## Catalog ↔ pricing cross-check
Catalog prices and context windows are cross-checked against LiteLLM's
-`model_prices_and_context_window.json`. The proxy's embedded
-`defaults_pricing.yaml` covers **every metered first-party model** the catalog
-enumerates — guarded by
-`TestDefaultTable_FirstPartyModelCoverage`
-([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)),
-which fails if a catalog model has no embedded price. Bedrock entries are keyed
-by the **normalised** id the request parser emits (region prefix + version
-suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices
-straight off the `anthropic` block.
+`model_prices_and_context_window.json`. The **catalog is the source of default
+prices**: management's `pricing.DefaultTable` folds every catalog provider's
+models into the surfaces that provider declares (`PricingSurfaces`), so coverage
+is structural rather than maintained in a parallel file
+([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)).
+`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up
+unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two
+providers contribute the same (surface, model) at different rates. Bedrock
+entries are keyed by the **normalised** id the request parser emits (region
+prefix + version suffix stripped) — management applies the same normalisation to
+per-provider prices at synth time, so the two keys compare equal. Vertex Claude
+carries no Bedrock-style prefix, so it prices straight off the `anthropic`
+surface.
## Things to scrutinise
@@ -232,16 +237,17 @@ operator-misconfigured Vertex provider and unmetered Gemini traffic; verify
publishers).
**Correctness.** `normalizeBedrockModel` is the join between the wire id and the
-pricing key — a model that normalises to something not in `defaults_pricing.yaml`
-meters at `cost.skipped=unknown_model` rather than failing the request. The
+pricing key — a model that normalises to something absent from the shipped
+pricing table meters at `cost.skipped=unknown_model` rather than failing the
+request. The
`/bedrock` prefix strip must run on both the parser side (so the model is
extracted) and the router side (so the upstream path is native); a regression in
either silently breaks the other.
**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a
-~10% premium not modelled by base pricing — flagged in both the catalog comment
-and `defaults_pricing.yaml`. Operators needing exact regional billing override
-the relevant entries.
+~10% premium not modelled by base pricing — flagged in the catalog comment.
+Operators needing exact regional billing set per-provider prices on the model
+rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`).
## Cross-references
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 @@
-->
+
+
+
+
+
+