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/de/common.json b/client/ui/i18n/locales/de/common.json
index 5e91e8d88..489cf9197 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"
},
@@ -1330,5 +1330,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 b668146e8..ae4f170a4 100644
--- a/client/ui/i18n/locales/en/common.json
+++ b/client/ui/i18n/locales/en/common.json
@@ -1775,16 +1775,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 c036e4f75..2f42ab8e8 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"
},
@@ -1330,5 +1330,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 c6b91fb25..97db6b039 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"
},
@@ -1330,5 +1330,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 dd5a1af6c..9604ce34f 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"
},
@@ -1330,5 +1330,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 7a2eb610c..fe6f2297d 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"
},
@@ -1330,5 +1330,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 326c825bf..4f9875680 100644
--- a/client/ui/i18n/locales/ja/common.json
+++ b/client/ui/i18n/locales/ja/common.json
@@ -1304,6 +1304,9 @@
"daemon.outdated.description": {
"message": "このアプリを使用するには NetBird サービスを更新してください。"
},
+ "daemon.outdated.download": {
+ "message": "最新版をダウンロード"
+ },
"error.jwt_clock_skew": {
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
},
@@ -1327,5 +1330,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 37b02d5a8..d08e8f230 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"
},
@@ -1330,5 +1330,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 b9ae59df2..75139496f 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": "Общие"
},
@@ -1330,5 +1330,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/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json
index 2141a770d..a7fb2294c 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": "常规"
},
@@ -1330,5 +1330,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 e2d172e5b..1b827034c 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)
diff --git a/client/ui/privileged_settings.go b/client/ui/privileged_settings.go
new file mode 100644
index 000000000..1e8b4bbf6
--- /dev/null
+++ b/client/ui/privileged_settings.go
@@ -0,0 +1,27 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/ui/services"
+)
+
+// The one-shot mode this binary runs itself in, elevated, to apply the settings the
+// daemon restricts to root/administrator. It is handled before anything GUI, so no
+// window, tray or single-instance lock is involved.
+//
+// Only the wiring is here: what the mode accepts and does lives beside the code
+// that asks for it, in services.RunPrivilegedSettings, so the settings it will
+// apply are declared once. There is nothing privileged about the mode itself; it
+// sends the same request the frontend would have sent, and the daemon authorizes it
+// from the identity the kernel reports on the control channel exactly as it does
+// for `sudo netbird up`.
+func runPrivilegedSettings(args []string) int {
+ return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
+ if addr == "" {
+ addr = DaemonAddr()
+ }
+ return NewConn(addr).Client()
+ })
+}
diff --git a/client/ui/services/guarded.go b/client/ui/services/guarded.go
new file mode 100644
index 000000000..13a47ccbd
--- /dev/null
+++ b/client/ui/services/guarded.go
@@ -0,0 +1,233 @@
+//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. Named here, next to
+// the code that builds the arguments; parsed by runPrivilegedSettings in the main
+// package. The setting flags deliberately spell the same words as `netbird up`,
+// so what the user is shown as a command and what runs behind the prompt read
+// alike.
+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 an
+// authentication dialog nobody ever answers does not leave the control it belongs
+// to disabled for the rest of 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).
+//
+// How much it can actually interrupt differs. On Linux the prompt is a child
+// process and is killed with the context; on Windows the wait for it is
+// interruptible. On macOS the dialog belongs to Security.framework, which offers no
+// way to withdraw the request, so there the timeout only stops us waiting — the
+// system's own dialog timeout is what ends it.
+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",
+ }
+ }
+
+ args := append([]string{
+ "--" + FlagApplyPrivilegedSettings,
+ "--" + FlagDaemonAddr, s.daemonAddr,
+ "--" + FlagProfile, p.ProfileName,
+ "--" + FlagUser, p.Username,
+ }, oneShotArgs(settings)...)
+
+ ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
+ defer cancel()
+
+ // Both ends of it: when the prompt went up, and what came of it. These are
+ // changes that hand out shells on this host, so the log should say who was
+ // asked and when, and it is also the only account of a prompt that was slow to
+ // appear or never answered. The daemon records the change itself, against the
+ // identity it authorized.
+ 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..237468ea3
--- /dev/null
+++ b/client/ui/services/guarded_test.go
@@ -0,0 +1,290 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "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"
+
+// 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 RPC 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
+ 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
+}
+
+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.
+func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
+ t.Helper()
+
+ refusal := privilegeRefusal(t)
+ daemon := &stubDaemon{}
+ 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",
+ "--" + FlagEnableSSHRoot + "=true",
+ }
+ require.Len(t, elev.calls, 1, "one prompt for one change")
+ assert.Equal(t, want, elev.calls[0], "elevated arguments")
+}
+
+// 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")
+}
+
+// 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..676b08005
--- /dev/null
+++ b/client/ui/services/oneshot.go
@@ -0,0 +1,233 @@
+//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/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 {
+ 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..2c66a94f4
--- /dev/null
+++ b/client/ui/services/oneshot_test.go
@@ -0,0 +1,166 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "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 as that process parses them.
+func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
+ t.Helper()
+
+ rendered := guardedSettings(p)
+ require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
+
+ values := make([]fieldValue, len(guardedFields))
+ for _, setting := range rendered {
+ flag, value, found := splitFlag(setting.arg)
+ require.True(t, found, "rendered %q without a value", setting.arg)
+
+ matched := false
+ for i, field := range guardedFields {
+ if field.flag != flag {
+ continue
+ }
+ require.NoError(t, values[i].Set(value))
+ matched = true
+ }
+ require.True(t, matched, "rendered %q, which no field claims", setting.arg)
+ }
+
+ req, err := privilegedRequest(p.ProfileName, p.Username, values)
+ require.NoError(t, err)
+ return req
+}
+
+// splitFlag takes "--name=value" apart the way the flag package does.
+func splitFlag(arg string) (name, value string, found bool) {
+ trimmed := arg
+ for len(trimmed) > 0 && trimmed[0] == '-' {
+ trimmed = trimmed[1:]
+ }
+ for i := 0; i < len(trimmed); i++ {
+ if trimmed[i] == '=' {
+ return trimmed[:i], trimmed[i+1:], true
+ }
+ }
+ return trimmed, "", false
+}
diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go
index 74e6f913c..dd4dd9b8b 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,68 @@ 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 := GuardedSettings{
+ ProfileName: p.ProfileName,
+ Username: p.Username,
+ ManagementURL: p.ManagementURL,
+ ServerSSHAllowed: p.ServerSSHAllowed,
+ EnableSSHRoot: p.EnableSSHRoot,
+ DisableSSHAuth: p.DisableSSHAuth,
+ }
+ 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
}
// 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 +297,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 {