mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
Authorize daemon IPC callers by their local identity
This commit is contained in:
@@ -18,6 +18,9 @@ type CopyToClipboardProps = {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// wrap lets long content (a shell command, a path) break across lines
|
||||
// instead of being truncated to one line.
|
||||
wrap?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
tabIndex?: number;
|
||||
@@ -32,6 +35,7 @@ export const CopyToClipboard = ({
|
||||
className,
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
wrap = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
tabIndex = 0,
|
||||
@@ -83,7 +87,8 @@ export const CopyToClipboard = ({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-w-0 truncate",
|
||||
"relative min-w-0",
|
||||
wrap ? "whitespace-pre-wrap break-all" : "truncate",
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
@@ -68,6 +68,21 @@ const useSettingsState = () => {
|
||||
loadedRef.current = loaded;
|
||||
}, [loaded]);
|
||||
|
||||
// reload re-reads the daemon's config, which is authoritative. Used on
|
||||
// mount, on the daemon's config_changed event, and to undo an optimistic
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
} catch (e) {
|
||||
console.warn("[SettingsContext] reload after rejected save failed", e);
|
||||
}
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfileId) return;
|
||||
let cancelled = false;
|
||||
@@ -133,13 +148,20 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
// field, ...). Snap the controls back to what it actually
|
||||
// holds before reporting, so the UI never shows a value the
|
||||
// daemon does not have.
|
||||
await reload(profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[username],
|
||||
[username, reload],
|
||||
);
|
||||
|
||||
const setField = useCallback(
|
||||
|
||||
32
client/ui/frontend/src/hooks/usePrivilege.ts
Normal file
32
client/ui/frontend/src/hooks/usePrivilege.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { 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
|
||||
// with the daemon's own rule, so there is no round-trip and it works while the
|
||||
// daemon is down.
|
||||
//
|
||||
// null means "not known yet", which includes the read having failed. Callers must
|
||||
// treat that as "do not restrict": the daemon enforces this regardless, so the
|
||||
// only thing a wrong guess here costs is a control that looks unavailable when it
|
||||
// is not, or a save that fails with the daemon's own guidance.
|
||||
export const usePrivilege = (): Privilege | null => {
|
||||
const [privilege, setPrivilege] = useState<Privilege | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
SettingsSvc.Privilege()
|
||||
.then((p) => {
|
||||
if (!cancelled) setPrivilege(p);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn("[usePrivilege] read failed, not restricting controls", e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return privilege;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string };
|
||||
type ClassifiedError = { short: string; long: string; command: string };
|
||||
|
||||
const asObject = (v: unknown): Record<string, unknown> | null =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
@@ -22,20 +22,24 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long } from wherever the classified error sits in the envelope
|
||||
// Read { short, long, command } from wherever the classified error sits in the envelope
|
||||
const toClassifiedError = (v: unknown): ClassifiedError | null => {
|
||||
const o = asObject(v);
|
||||
if (!o) return null;
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
return short || long ? { short, long } : null;
|
||||
const command = typeof o.command === "string" ? o.command : "";
|
||||
return short || long ? { short, long, command } : null;
|
||||
};
|
||||
|
||||
const classify = (e: unknown): ClassifiedError | null => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
const classified = classify(e);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
@@ -44,17 +48,26 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
}
|
||||
|
||||
// Unclassified (a service returned the raw daemon error)
|
||||
const envelope = toWailsEnvelope(e);
|
||||
const message = envelope?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
// errorCommand returns a command the user can run to complete an operation the
|
||||
// daemon refused, when the error carries one (a change that needs elevated
|
||||
// privileges). Empty for every other error.
|
||||
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
// Command is shown for copying below the message. Defaults to the one the
|
||||
// error carries, so callers only pass it to override.
|
||||
Command?: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
@@ -12,14 +13,22 @@ import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
// A command needs the room to wrap at a sensible number of characters instead of
|
||||
// breaking every few words.
|
||||
const WINDOW_WIDTH_WITH_COMMAND = 460;
|
||||
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
// Set when the daemon refused an operation that needs elevated privileges:
|
||||
// the command that performs it, offered for copying.
|
||||
const command = params.get("command") || "";
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(
|
||||
command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH,
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
@@ -37,15 +46,37 @@ export default function ErrorDialog() {
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<div className={"flex w-full flex-col items-center gap-1"}>
|
||||
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
|
||||
{title}
|
||||
</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
{/* select-text: the message often names a path, a flag or an
|
||||
address the user needs to act on. */}
|
||||
<span className={"select-text whitespace-pre-wrap break-words"}>
|
||||
{message}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
{command && (
|
||||
<CopyToClipboard
|
||||
message={command}
|
||||
alwaysShowIcon
|
||||
wrap
|
||||
variant={"bright"}
|
||||
className={
|
||||
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<code
|
||||
className={"select-text break-all font-mono text-xs text-nb-gray-200"}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
@@ -6,12 +7,50 @@ 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 ChangeEvent, useEffect, useId, useState } from "react";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { 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 privilege = usePrivilege();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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,
|
||||
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,
|
||||
) => {
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.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 jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -46,9 +85,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
/>
|
||||
{sshServer.hint}
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup
|
||||
@@ -58,9 +99,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
/>
|
||||
{sshRoot.hint}
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
@@ -88,9 +131,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
disabled={sshAuth.disabled}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
/>
|
||||
{sshAuth.hint}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6",
|
||||
@@ -117,3 +162,42 @@ 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({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (!command) return null;
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user