mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-02 13:01:29 +02:00
Merge branch 'main' into mdm_integration
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=Netbird
|
||||
Name=NetBird
|
||||
Comment=NetBird desktop client
|
||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||
Icon=netbird
|
||||
Type=Application
|
||||
|
||||
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
|
||||
<!--
|
||||
Names the action behind the elevation prompt the desktop app raises for an SSH
|
||||
setting the daemon restricts to root; without it pkexec's generic dialog offers
|
||||
the raw command line instead. The argv1 annotation keeps this wording to the
|
||||
one-shot mode that applies those settings.
|
||||
|
||||
auth_admin rather than auth_admin_keep: each of these settings is its own grant
|
||||
of shell access, so a credential cache would let a second, unasked-for change
|
||||
ride along on the authorization given the first.
|
||||
|
||||
exec.path takes no wildcard and the binary's location depends on the package,
|
||||
hence one action per path.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>NetBird</vendor>
|
||||
<vendor_url>https://netbird.io</vendor_url>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged-local">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -22,12 +22,18 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
// GuardedField is a setting the daemon only accepts from root/administrator.
|
||||
// Turning one on goes through saveGuardedField, which asks the operating system
|
||||
// for the privileges rather than sending a request that would be refused.
|
||||
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
guiVersion: string;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -63,6 +69,12 @@ const useSettingsState = () => {
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||
// Set when the daemon's config changed while a save was pending, so the read
|
||||
// that was skipped to protect the pending edit happens once it is through.
|
||||
// Without it the form keeps values the daemon no longer has and the next save
|
||||
// submits them, which for a guarded setting means asking the user to authorize
|
||||
// a change they never made.
|
||||
const reloadOwed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadedRef.current = loaded;
|
||||
@@ -73,6 +85,7 @@ const useSettingsState = () => {
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
reloadOwed.current = false;
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
@@ -94,7 +107,12 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (saveTimer.current) return;
|
||||
// A pending edit outranks the daemon's copy until it is saved, so
|
||||
// the read is owed rather than dropped: see reloadOwed.
|
||||
if (saveTimer.current) {
|
||||
reloadOwed.current = true;
|
||||
return;
|
||||
}
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
@@ -141,12 +159,17 @@ const useSettingsState = () => {
|
||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
const { declined } = await SettingsSvc.SetConfig({
|
||||
...next,
|
||||
...preSharedKeyWrite,
|
||||
profileName,
|
||||
username,
|
||||
});
|
||||
// The change needed authorization and the user said no, so the
|
||||
// optimistic update is wrong. Nothing to report: they know.
|
||||
if (declined || reloadOwed.current) {
|
||||
await reload(profileName);
|
||||
}
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
@@ -206,6 +229,59 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
// saveGuardedField applies a setting the daemon restricts to
|
||||
// root/administrator by having the Go side run the app again under the
|
||||
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
|
||||
// The prompt is the user's, so the call is made straight from their gesture
|
||||
// and never from the debounce.
|
||||
const saveGuardedField = useCallback(
|
||||
async (k: GuardedField, v: boolean) => {
|
||||
const cur = loadedRef.current;
|
||||
if (!cur) return;
|
||||
|
||||
// Flush what the debounce still owes, before the optimistic update
|
||||
// below joins it: a later save carrying the guarded value would be
|
||||
// refused, and its error dialog would be the second one for a change
|
||||
// the user already authorized.
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
await save(cur.profileName, cur.data);
|
||||
}
|
||||
|
||||
const next: LoadedConfig = {
|
||||
profileName: cur.profileName,
|
||||
data: { ...cur.data, [k]: v },
|
||||
};
|
||||
loadedRef.current = next;
|
||||
setLoaded(next);
|
||||
|
||||
try {
|
||||
await SettingsSvc.SetGuardedSettings({
|
||||
profileName: cur.profileName,
|
||||
username,
|
||||
[k]: v,
|
||||
});
|
||||
} catch (e) {
|
||||
// The daemon is authoritative either way, so re-read before
|
||||
// reporting. A declined prompt is not an error and does not come
|
||||
// through here at all; this is a prompt that could not be raised,
|
||||
// which carries the command that would have done it.
|
||||
await reload(cur.profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Either the change went through or the user declined it. The daemon
|
||||
// says which.
|
||||
await reload(cur.profileName);
|
||||
},
|
||||
[username, save, reload],
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||
if (!loaded) return;
|
||||
@@ -225,15 +301,27 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
||||
return {
|
||||
config: loaded?.data ?? null,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveGuardedField,
|
||||
saveNow,
|
||||
};
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
() =>
|
||||
config
|
||||
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||
: null,
|
||||
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type Privilege } from "@bindings/services/models.js";
|
||||
|
||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
||||
// restricts to root/administrator. It is answered in-process from our own token
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
@@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import type { Privilege } from "@bindings/services/models.js";
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { config, setField, saveGuardedField } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
// The field whose elevation prompt is currently up, if any. The prompt is
|
||||
// modal to the operating system, not to us, so the guarded controls are held
|
||||
// still meanwhile rather than allowed to stack a second one behind it.
|
||||
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
const authorize = async (field: GuardedField, value: boolean) => {
|
||||
setAuthorizing(field);
|
||||
try {
|
||||
await saveGuardedField(field, value);
|
||||
} finally {
|
||||
setAuthorizing(null);
|
||||
}
|
||||
};
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch
|
||||
// (it is on, they may turn it off, but not back on) — say which, either way.
|
||||
// running as root: for all three settings that is switching the field on.
|
||||
//
|
||||
// An unprivileged user gets that direction routed through the platform's
|
||||
// elevation prompt where there is one to raise, and otherwise the old
|
||||
// arrangement, where the control is either unavailable (it is off and only a
|
||||
// privileged caller could turn it on) or a one-way switch (it is on, they may
|
||||
// turn it off but not back on) with the command that does it.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
const guarded = (
|
||||
guardedDirectionActive: boolean,
|
||||
field: GuardedField,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off, so
|
||||
// the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
) => {
|
||||
const plain = (value: boolean) => setField(field, value);
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
return { apply: plain, disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
|
||||
const guardedDirectionActive = config[field];
|
||||
const hint = (pending: boolean, command?: string) => (
|
||||
<GuardedHint
|
||||
actor={actorLabel(privilege, t)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
pending={pending}
|
||||
command={command}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
|
||||
if (privilege.canElevate) {
|
||||
return {
|
||||
// Switching off is ours to do; only switching on is authorized.
|
||||
apply: (value: boolean) => {
|
||||
if (!value) {
|
||||
plain(value);
|
||||
return;
|
||||
}
|
||||
void authorize(field, value);
|
||||
},
|
||||
disabled: authorizing !== null,
|
||||
hint: hint(authorizing === field),
|
||||
};
|
||||
}
|
||||
return {
|
||||
apply: plain,
|
||||
disabled: !guardedDirectionActive,
|
||||
hint: hint(false, command(privilege)),
|
||||
};
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
||||
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
|
||||
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
|
||||
// Inverted control: the guarded direction is switching authentication off, so
|
||||
// it is the already-disabled state that is the one-way one.
|
||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
||||
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -84,7 +125,7 @@ export function SettingsSSH() {
|
||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
onChange={sshServer.apply}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
@@ -98,7 +139,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
onChange={sshRoot.apply}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
@@ -130,7 +171,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => 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 <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
|
||||
}
|
||||
if (oneWay) {
|
||||
return (
|
||||
<HintBox>
|
||||
<span>
|
||||
{inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
if (!command) return null;
|
||||
return (
|
||||
<HintBox>
|
||||
<span>{t("settings.ssh.privilege.hint", { actor })}</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.ssh.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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, aber zum erneuten Aktivieren sind {actor} erforderlich:"
|
||||
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:"
|
||||
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Warten auf Autorisierung…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}:"
|
||||
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:"
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Esperando la autorización…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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} :"
|
||||
"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} :"
|
||||
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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:"
|
||||
"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:"
|
||||
"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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}:"
|
||||
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:"
|
||||
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "In attesa dell'autorizzazione…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}が必要です:"
|
||||
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "有効にはできますが、再度無効にするには{actor}が必要です:"
|
||||
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "承認を待っています…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}:"
|
||||
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Você pode ativar isto, mas desativar novamente requer {actor}:"
|
||||
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Aguardando a autorização…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Общие"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}:"
|
||||
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Включить можно, но чтобы отключить снова, нужны {actor}:"
|
||||
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Ожидание авторизации…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "常规"
|
||||
},
|
||||
@@ -1351,13 +1351,28 @@
|
||||
"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}:"
|
||||
"message": "您可以关闭此项,但重新开启需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "您可以开启此项,但再次关闭需要{actor}:"
|
||||
"message": "您可以开启此项,但再次关闭需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "正在等待授权…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
27
client/ui/privileged_settings.go
Normal file
27
client/ui/privileged_settings.go
Normal file
@@ -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()
|
||||
})
|
||||
}
|
||||
231
client/ui/services/guarded.go
Normal file
231
client/ui/services/guarded.go
Normal file
@@ -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)
|
||||
}
|
||||
355
client/ui/services/guarded_test.go
Normal file
355
client/ui/services/guarded_test.go
Normal file
@@ -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")
|
||||
}
|
||||
239
client/ui/services/oneshot.go
Normal file
239
client/ui/services/oneshot.go
Normal file
@@ -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 <url>` 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)
|
||||
151
client/ui/services/oneshot_test.go
Normal file
151
client/ui/services/oneshot_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -25,12 +25,19 @@ type Features = mdm.Features
|
||||
type Restrictions = mdm.Restrictions
|
||||
|
||||
// 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"`
|
||||
@@ -109,6 +116,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 {
|
||||
@@ -116,6 +126,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
elevator: osElevator{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +172,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,
|
||||
@@ -196,19 +207,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
|
||||
@@ -218,26 +302,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 {
|
||||
@@ -270,6 +368,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 {
|
||||
|
||||
Reference in New Issue
Block a user