[Client] Surface readable Authz errors and add profile claim command (#7540)

* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
Theodor Midtlien
2026-09-17 14:36:56 +02:00
committed by GitHub
parent 801557e21b
commit cec9ee6699
69 changed files with 2912 additions and 681 deletions
@@ -11,7 +11,7 @@ import { Label } from "@/components/typography/Label";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor } from "@/lib/errors";
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
@@ -66,10 +66,7 @@ export function LanguagePicker() {
try {
await Preferences.SetLanguage(code as LanguageCode);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(t("settings.error.saveTitle"), e);
} finally {
setBusy(false);
}
@@ -13,12 +13,7 @@ import { Events } from "@wailsio/runtime";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const isDaemonUnavailable = (e: unknown): boolean => {
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
};
import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors";
type ClientVersionContextValue = {
updateAvailable: boolean;
@@ -61,10 +56,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
})
.catch((e) => {
if (cancelled || isDaemonUnavailable(e)) return;
void errorDialog({
Title: i18next.t("update.error.loadStateTitle"),
Message: formatErrorMessage(e),
});
void errorDialogFor(i18next.t("update.error.loadStateTitle"), e);
});
const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
if (ev?.data) setState(ev.data);
@@ -90,10 +82,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
.catch(async (e) => {
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await errorDialog({
Title: i18next.t("update.error.triggerTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(i18next.t("update.error.triggerTitle"), e);
})
.finally(() => setUpdating(false));
}, [state.version]);
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { errorDialogFor } from "@/lib/errors.ts";
import { startConnection } from "@/lib/connection.ts";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
@@ -260,10 +260,7 @@ const useDebugBundle = () => {
}
await cleanupBestEffort(pcap, level, false);
setStage({ kind: "idle" });
await errorDialog({
Title: i18next.t("settings.error.debugBundleTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(i18next.t("settings.error.debugBundleTitle"), e);
} finally {
if (abortRef.current === ctrl) abortRef.current = null;
}
@@ -12,7 +12,7 @@ import { Events } from "@wailsio/runtime";
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors";
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
@@ -65,23 +65,27 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
ProfilesSvc.List(u),
]);
setUsername(u);
setActiveProfile(active.profileName || "default");
// An empty name means the daemon would not disclose it: the active
// profile belongs to another user. Falling back to "default" would
// name the wrong profile, so say what it is instead.
const activeName = active.profileName
? active.profileName
: active.id
? i18next.t("profile.ownedByAnother")
: "default";
setActiveProfile(activeName);
setActiveProfileId(active.id || "default");
setProfiles(list);
setLoaded(true);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("code = Unavailable")) {
if (isDaemonUnavailable(e)) {
retryRef.current = setTimeout(() => {
void refresh();
}, 1000);
return;
}
setLoaded(true);
await errorDialog({
Title: i18next.t("profile.error.loadTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(i18next.t("profile.error.loadTitle"), e);
}
}, []);
@@ -9,12 +9,12 @@ import {
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
import { Autostart, Settings as SettingsSvc } from "@bindings/services";
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 { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
import { errorDialogFor } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
@@ -29,7 +29,6 @@ export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAut
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>;
@@ -66,7 +65,6 @@ type LoadedConfig = { profileName: string; data: Config };
const useSettingsState = () => {
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
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
@@ -116,10 +114,7 @@ const useSettingsState = () => {
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
await errorDialog({
Title: i18next.t("settings.error.loadTitle"),
Message: errorMessage(e),
});
await errorDialogFor(i18next.t("settings.error.loadTitle"), e);
}
};
@@ -138,16 +133,6 @@ const useSettingsState = () => {
};
}, [profileLoaded, activeProfileId, username]);
useEffect(() => {
let cancelled = false;
Version.GUI().then((v) => {
if (!cancelled) setGuiVersion(v);
});
return () => {
cancelled = true;
};
}, []);
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
@@ -177,11 +162,7 @@ const useSettingsState = () => {
// 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),
});
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
}
},
[username, reload],
@@ -268,11 +249,7 @@ const useSettingsState = () => {
// 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),
});
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
return;
}
// Either the change went through or the user declined it. The daemon
@@ -303,7 +280,6 @@ const useSettingsState = () => {
return {
config: loaded?.data ?? null,
guiVersion,
setField,
saveField,
saveFields,
@@ -313,15 +289,13 @@ const useSettingsState = () => {
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
const { config, setField, saveField, saveFields, saveGuardedField, saveNow } =
useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() =>
config
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
: null,
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
config ? { config, setField, saveField, saveFields, saveGuardedField, saveNow } : null,
[config, setField, saveField, saveFields, saveGuardedField, saveNow],
);
if (!value) {
@@ -361,10 +335,7 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
await Autostart.SetEnabled(enabled);
} catch (e) {
setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
await errorDialog({
Title: i18next.t("settings.general.autostart.errorTitle"),
Message: errorMessage(e),
});
await errorDialogFor(i18next.t("settings.general.autostart.errorTitle"), e);
}
}, []);
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { Version } from "@bindings/services";
const UNKNOWN_VERSION = "—";
// useGuiVersion reports the UI binary's own version, which is stamped into it at
// build time and answered in-process. The daemon version comes from the status
// feed instead, see StatusContext.
export const useGuiVersion = (): string => {
const [guiVersion, setGuiVersion] = useState<string>(UNKNOWN_VERSION);
useEffect(() => {
let cancelled = false;
Version.GUI()
.then((v) => {
if (!cancelled) setGuiVersion(v);
})
.catch((e: unknown) => {
console.warn("[useGuiVersion] read failed", e);
});
return () => {
cancelled = true;
};
}, []);
return guiVersion;
};
+2 -5
View File
@@ -1,7 +1,7 @@
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { errorDialogFor } from "@/lib/errors.ts";
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
export const EVENT_TRIGGER_LOGIN = "trigger-login";
@@ -120,10 +120,7 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
}
if (connectError !== undefined) {
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: formatErrorMessage(connectError),
});
await errorDialogFor(i18next.t("connect.error.loginTitle"), connectError);
return;
}
+30 -5
View File
@@ -1,6 +1,6 @@
import { WindowManager } from "@bindings/services";
type ClassifiedError = { short: string; long: string; command: string };
type ClassifiedError = { code: string; 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,14 +22,15 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
return asObject(obj.cause) ?? parseJsonObject(obj.message);
};
// Read { short, long, command } from wherever the classified error sits in the envelope
// Read { code, 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 code = typeof o.code === "string" ? o.code : "";
const short = typeof o.short === "string" ? o.short : "";
const long = typeof o.long === "string" ? o.long : "";
const command = typeof o.command === "string" ? o.command : "";
return short || long ? { short, long, command } : null;
return short || long ? { code, short, long, command } : null;
};
const classify = (e: unknown): ClassifiedError | null => {
@@ -60,14 +61,38 @@ export const formatErrorMessage = (e: unknown): string => {
// privileges). Empty for every other error.
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
// isDaemonUnavailable reports whether an error means the daemon could not be
// reached, so a caller can retry quietly instead of putting a dialog up while
// the service is still starting. Matches the classified code first and the raw
// gRPC status text second, since not every service classifies its errors.
export const isDaemonUnavailable = (e: unknown): boolean => {
if (classify(e)?.code === "daemon_unreachable") return true;
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
};
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 is shown for copying below the message. Prefer errorDialogFor,
// which takes it from the error, over setting this by hand.
Command?: string;
};
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
}
// errorDialogFor opens a dialog for a thrown error, taking both the message and
// any command the daemon attached from the error itself.
//
// Use it wherever the message is just the error. Passing Command by hand is what
// kept the daemon's suggested command off the screen everywhere except Settings,
// since every other caller had to remember to ask for it.
export function errorDialogFor(title: string, e: unknown): Promise<void> {
return errorDialog({
Title: title,
Message: formatErrorMessage(e),
Command: errorCommand(e),
});
}
@@ -11,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor } from "@/lib/errors";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
@@ -25,10 +25,7 @@ export default function LoginWaitingForBrowserDialog() {
const reportOpenFailure = useCallback(
(e: unknown) => {
void errorDialog({
Title: t("browserLogin.openFailedTitle"),
Message: formatErrorMessage(e),
});
void errorDialogFor(t("browserLogin.openFailedTitle"), e);
},
[t],
);
@@ -6,7 +6,7 @@ import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { errorDialogFor } from "@/lib/errors.ts";
import {
startConnection,
EVENT_BROWSER_LOGIN_CANCEL,
@@ -40,8 +40,6 @@ const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
@@ -100,10 +98,7 @@ export const MainConnectionStatusSwitch = () => {
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.connectTitle"),
Message: errorMessage(e),
});
await errorDialogFor(t("connect.error.connectTitle"), e);
}
};
@@ -115,10 +110,7 @@ export const MainConnectionStatusSwitch = () => {
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
await errorDialogFor(t("connect.error.disconnectTitle"), e);
}
};
@@ -209,10 +201,7 @@ export const MainConnectionStatusSwitch = () => {
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
await errorDialogFor(t("connect.error.disconnectTitle"), e);
}
};
const show = connState === ConnectionState.Connected;
@@ -10,7 +10,7 @@ import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -45,10 +45,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
await errorDialogFor(title, e);
} finally {
setBusy(false);
}
@@ -34,7 +34,7 @@ import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor } from "@/lib/errors";
const DEFAULT_PROFILE_ID = "default";
@@ -84,10 +84,7 @@ export function ProfilesTab() {
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
await errorDialogFor(title, e);
} finally {
setBusy(false);
}
@@ -12,7 +12,7 @@ import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { errorDialogFor } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
@@ -159,10 +159,7 @@ export default function SessionExpirationDialog() {
WindowManager.CloseRenewFlow().catch(console.error);
} catch (e) {
resetDialog();
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(t("sessionExpiration.extendFailedTitle"), e);
}
}, [busy, t]);
@@ -174,10 +171,7 @@ export default function SessionExpirationDialog() {
await WindowManager.CloseSessionExpiration();
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("connect.error.loginTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(t("connect.error.loginTitle"), e);
}
}, [busy, t]);
@@ -194,10 +188,7 @@ export default function SessionExpirationDialog() {
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(t("sessionExpiration.logoutFailedTitle"), e);
}
}, [busy, t]);
@@ -24,8 +24,8 @@ const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
/>
</svg>
);
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { useGuiVersion } from "@/hooks/useGuiVersion";
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
@@ -38,7 +38,7 @@ function openUrl(url: string) {
export function SettingsAbout() {
const { t } = useTranslation();
const { status } = useStatus();
const { guiVersion } = useSettings();
const guiVersion = useGuiVersion();
const daemonVersion = status?.daemonVersion ?? "—";
const handleVersionClick = useAccentTrigger();
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
@@ -31,6 +31,17 @@ const enum Tab {
About = "about",
}
// Tabs that render the daemon's profile configuration. Only these mount
// SettingsProvider, so a profile whose configuration this user may not read
// still leaves the rest of the page reachable.
const CONFIG_TABS: ReadonlySet<Tab> = new Set([
Tab.General,
Tab.Network,
Tab.Security,
Tab.SSH,
Tab.Advanced,
]);
const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
@@ -42,9 +53,16 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.About]: <SettingsAbout />,
};
// WithSettings reads the daemon's profile configuration for the tabs that need
// it. Radix keeps only the active tab's content mounted, so gating on the active
// tab is what keeps the read off the tabs that do not use it.
const WithSettings = ({ enabled, children }: { enabled: boolean; children: ReactNode }) =>
enabled ? <SettingsProvider>{children}</SettingsProvider> : <>{children}</>;
export const SettingsPage = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const [searchParams] = useSearchParams();
const { mdm, features } = useRestrictions();
const visibleTabs = useMemo<Tab[]>(() => {
@@ -63,7 +81,11 @@ export const SettingsPage = () => {
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
const defaultTab = visibleTabs[0];
const [active, setActive] = useState<string>(() => navState?.tab ?? defaultTab);
// The window carries its tab in the URL, so the first render opens on the
// requested one rather than on the default and then correcting itself.
const [active, setActive] = useState<string>(
() => navState?.tab ?? searchParams.get("tab") ?? defaultTab,
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
@@ -92,7 +114,7 @@ export const SettingsPage = () => {
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<SettingsProvider>
<WithSettings enabled={CONFIG_TABS.has(active as Tab)}>
<ScrollArea.Root
key={active}
type={"auto"}
@@ -121,7 +143,7 @@ export const SettingsPage = () => {
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</SettingsProvider>
</WithSettings>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
@@ -8,7 +8,7 @@ import {
import { Restrictions, SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { errorDialogFor } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isNetbirdCloud } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
@@ -130,10 +130,7 @@ export default function WelcomeDialog() {
}),
);
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
throw e;
}
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Warten auf Autorisierung…"
},
"profile.ownedByAnother": {
"message": "Profil eines anderen Benutzers"
},
"error.privilege_required": {
"message": "Diese Aktion erfordert erhöhte Rechte."
},
"error.session_held": {
"message": "Ein anderer Benutzer hat diesen Rechner verbunden."
},
"error.not_profile_owner": {
"message": "Dieses Profil gehört einem anderen Benutzer."
}
}
+16
View File
@@ -1850,5 +1850,21 @@
"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."
},
"profile.ownedByAnother": {
"message": "Another user's profile",
"description": "Shown in place of the active profile's name when it belongs to a different user account."
},
"error.privilege_required": {
"message": "This action requires elevated privileges.",
"description": "Short headline when the daemon refuses an action that needs elevated privileges. The daemon's own sentence, naming the action and what it needs, is shown as the detail."
},
"error.session_held": {
"message": "Another user has this machine connected.",
"description": "Short headline when the daemon refuses an action because a different user account holds the active connection. The daemon's own sentence is shown as the detail."
},
"error.not_profile_owner": {
"message": "This profile belongs to another user.",
"description": "Short headline when the daemon refuses an action because the profile it addresses is owned by a different user account."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Esperando la autorización…"
},
"profile.ownedByAnother": {
"message": "Perfil de otro usuario"
},
"error.privilege_required": {
"message": "Esta acción requiere privilegios elevados."
},
"error.session_held": {
"message": "Otro usuario tiene esta máquina conectada."
},
"error.not_profile_owner": {
"message": "Este perfil pertenece a otro usuario."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "En attente de l’autorisation…"
},
"profile.ownedByAnother": {
"message": "Profil d’un autre utilisateur"
},
"error.privilege_required": {
"message": "Cette action nécessite des privilèges élevés."
},
"error.session_held": {
"message": "Un autre utilisateur a connecté cette machine."
},
"error.not_profile_owner": {
"message": "Ce profil appartient à un autre utilisateur."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Várakozás az engedélyezésre…"
},
"profile.ownedByAnother": {
"message": "Másik felhasználó profilja"
},
"error.privilege_required": {
"message": "Ehhez a művelethez emelt szintű jogosultság szükséges."
},
"error.session_held": {
"message": "Egy másik felhasználó csatlakoztatta ezt a gépet."
},
"error.not_profile_owner": {
"message": "Ez a profil egy másik felhasználóé."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "In attesa dell'autorizzazione…"
},
"profile.ownedByAnother": {
"message": "Profilo di un altro utente"
},
"error.privilege_required": {
"message": "Questa azione richiede privilegi elevati."
},
"error.session_held": {
"message": "Un altro utente è già connesso su questa macchina."
},
"error.not_profile_owner": {
"message": "Questo profilo appartiene a un altro utente."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "承認を待っています…"
},
"profile.ownedByAnother": {
"message": "別のユーザーのプロファイル"
},
"error.privilege_required": {
"message": "この操作には昇格した権限が必要です。"
},
"error.session_held": {
"message": "別のユーザーがこのマシンを接続しています。"
},
"error.not_profile_owner": {
"message": "このプロファイルは別のユーザーのものです。"
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Aguardando a autorização…"
},
"profile.ownedByAnother": {
"message": "Perfil de outro usuário"
},
"error.privilege_required": {
"message": "Esta ação requer privilégios elevados."
},
"error.session_held": {
"message": "Outro usuário está com esta máquina conectada."
},
"error.not_profile_owner": {
"message": "Este perfil pertence a outro usuário."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Ожидание авторизации…"
},
"profile.ownedByAnother": {
"message": "Профиль другого пользователя"
},
"error.privilege_required": {
"message": "Для этого действия нужны повышенные права."
},
"error.session_held": {
"message": "Другой пользователь подключил эту машину."
},
"error.not_profile_owner": {
"message": "Этот профиль принадлежит другому пользователю."
}
}
+12
View File
@@ -1387,5 +1387,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "Очікування авторизації…"
},
"profile.ownedByAnother": {
"message": "Профіль іншого користувача"
},
"error.privilege_required": {
"message": "Ця дія потребує підвищених привілеїв."
},
"error.session_held": {
"message": "Інший користувач підключив цю машину."
},
"error.not_profile_owner": {
"message": "Цей профіль належить іншому користувачеві."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "正在等待授权…"
},
"profile.ownedByAnother": {
"message": "其他用户的配置文件"
},
"error.privilege_required": {
"message": "此操作需要提升的权限。"
},
"error.session_held": {
"message": "其他用户已连接此机器。"
},
"error.not_profile_owner": {
"message": "此配置文件属于其他用户。"
}
}
+3 -3
View File
@@ -105,11 +105,9 @@ func main() {
}
})
profiles := services.NewProfiles(conn)
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
// Update service is a thin Wails-bound facade over it plus the install RPCs.
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := newNotifier()
compat := services.NewCompat(conn)
@@ -128,6 +126,8 @@ func main() {
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
// After bundle + prefStore: both are used to localise daemon errors.
profiles := services.NewProfiles(conn, bundle, prefStore)
update := services.NewUpdate(conn, updaterHolder, bundle, prefStore)
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
connection := services.NewConnection(conn, bundle, prefStore)
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
@@ -338,7 +338,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.networks))
app.RegisterService(application.NewService(services.NewForwarding(conn)))
app.RegisterService(application.NewService(s.profiles))
app.RegisterService(application.NewService(services.NewDebug(conn)))
app.RegisterService(application.NewService(services.NewDebug(conn, s.bundle, s.prefStore)))
app.RegisterService(application.NewService(s.update))
app.RegisterService(application.NewService(s.daemonFeed))
app.RegisterService(application.NewService(s.notifier))
+25 -16
View File
@@ -38,17 +38,20 @@ type LogLevel struct {
}
type Debug struct {
conn DaemonConn
conn DaemonConn
classifier errorClassifier
}
func NewDebug(conn DaemonConn) *Debug {
return &Debug{conn: conn}
// NewDebug wires up a Debug service. translator or prefs may be nil, in which
// case classification falls back to the bare error key.
func NewDebug(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Debug {
return &Debug{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) {
cli, err := s.conn.Client()
if err != nil {
return DebugBundleResult{}, err
return DebugBundleResult{}, s.classifier.classify(err)
}
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
Anonymize: p.Anonymize,
@@ -59,7 +62,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
CliVersion: version.NetbirdVersion(),
})
if err != nil {
return DebugBundleResult{}, err
return DebugBundleResult{}, s.classifier.classify(err)
}
return DebugBundleResult{
Path: resp.GetPath(),
@@ -71,11 +74,11 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
cli, err := s.conn.Client()
if err != nil {
return LogLevel{}, err
return LogLevel{}, s.classifier.classify(err)
}
resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
if err != nil {
return LogLevel{}, err
return LogLevel{}, s.classifier.classify(err)
}
return LogLevel{Level: resp.GetLevel().String()}, nil
}
@@ -104,29 +107,33 @@ func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
req := &proto.StartBundleCaptureRequest{}
if timeoutSeconds > 0 {
req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second)
}
_, err = cli.StartBundleCapture(ctx, req)
return err
if _, err := cli.StartBundleCapture(ctx, req); err != nil {
return s.classifier.classify(err)
}
return nil
}
func (s *Debug) StopBundleCapture(ctx context.Context) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
_, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{})
return err
if _, err := cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}); err != nil {
return s.classifier.classify(err)
}
return nil
}
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
// proto.LogLevel_value keys are upper-case enum names; callers pass
// lowercase logrus names. Upper-case before lookup or a valid level
@@ -135,6 +142,8 @@ func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
if !ok {
level = int32(proto.LogLevel_INFO)
}
_, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)})
return err
if _, err := cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}); err != nil {
return s.classifier.classify(err)
}
return nil
}
+44 -31
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -15,19 +14,27 @@ import (
"github.com/netbirdio/netbird/client/ui/preferences"
)
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
// carries one.
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
for _, detail := range gstatus.Convert(err).Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
return info, true
}
// denialCode maps a refusal to the code the frontend presents it by, reporting
// false for a reason this build does not know. An unknown reason keeps the
// summary the daemon wrote and loses only the tailored presentation, which is
// what makes adding a reason daemon-side safe.
func denialCode(reason string) (string, bool) {
switch reason {
case ipcauth.ErrorReasonPrivilegeRequired:
return "privilege_required", true
case ipcauth.ErrorReasonSessionHeld:
return "session_held", true
case ipcauth.ErrorReasonNotProfileOwner:
return "not_profile_owner", true
default:
return "permission_denied", false
}
return nil, false
}
// privilegeRefused reports whether the daemon refused for want of privileges.
func privilegeRefused(err error) bool {
denial, ok := ipcauth.DenialFrom(err)
return ok && denial.Reason == ipcauth.ErrorReasonPrivilegeRequired
}
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
@@ -40,9 +47,10 @@ type LanguagePreference interface {
Get() preferences.UIPreferences
}
// ClientError is a structured error returned to the frontend. The frontend
// translates Code via i18n; Short is an English fallback; Long carries the
// unwrapped daemon message.
// ClientError is a structured error returned to the frontend. Short is the
// localised headline, Long the unwrapped daemon message shown under it, and Code
// the stable identifier Short was resolved from. The frontend reads Short, Long
// and Command; it does not translate Code itself.
type ClientError struct {
Code string `json:"code"`
Short string `json:"short"`
@@ -94,21 +102,8 @@ func (c errorClassifier) classify(err error) *ClientError {
grpcCode = st.Code()
}
// A refusal for want of privileges carries its own summary and the command
// that performs the operation, both written for the user. Surface them
// verbatim: no substring guessing, and no localisation of a message the
// daemon composed.
if info, ok := privilegeErrorInfo(err); ok {
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
if summary == "" {
summary = msg
}
return &ClientError{
Code: "privilege_required",
Short: summary,
Long: summary,
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
}
if denial, ok := ipcauth.DenialFrom(err); ok {
return c.classifyDenial(denial)
}
lower := strings.ToLower(msg)
@@ -155,6 +150,24 @@ func (c errorClassifier) classify(err error) *ClientError {
}
}
// classifyDenial presents a refusal the daemon explained: a localised headline
// for the reasons this build knows, with the daemon's own sentence as the
// detail the frontend shows under it. An unrecognised reason keeps that sentence
// as the headline too, so a reason added daemon-side still reaches the user.
func (c errorClassifier) classifyDenial(denial ipcauth.Denial) *ClientError {
code, known := denialCode(denial.Reason)
short := denial.Summary
if known {
short = c.translateShort(code)
}
return &ClientError{
Code: code,
Short: short,
Long: denial.Summary,
Command: denial.Command,
}
}
// translateShort resolves the localised short message for code, returning the
// bare "error.<code>" key when no translation is available so the gap stays visible.
func (c errorClassifier) translateShort(code string) string {
+109
View File
@@ -4,11 +4,17 @@ package services
import (
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/ui/i18n"
)
func TestErrorClassifier_Classify(t *testing.T) {
@@ -48,3 +54,106 @@ func TestErrorClassifier_Classify(t *testing.T) {
require.Nil(t, c.classify(nil))
})
}
// Every reason the daemon explains gets its own code, and the headline is looked
// up from that code rather than repeating the daemon's sentence, so a held
// session reads differently from a privilege refusal.
func TestClassifyMapsEveryDaemonReason(t *testing.T) {
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
for _, tc := range []struct {
name string
err error
code string
command bool
}{
{"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true},
{"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true},
{"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.code, got.Code)
assert.Equal(t, "error."+tc.code, got.Short, "Short comes from the locale bundle, not the daemon")
assert.NotEmpty(t, got.Long, "the daemon's sentence has to reach the user")
assert.NotContains(t, got.Long, "rpc error")
assert.Equal(t, tc.command, got.Command != "")
})
}
}
// Every denial code needs an entry in the shipped bundle, or the dialog shows a
// bare "error.<code>" key where the headline should be. Resolved against the real
// locale tree so a reason added without a translation fails here, not on screen.
func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) {
bundle, err := i18n.NewBundle(os.DirFS("../i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
c := errorClassifier{translator: bundle}
for _, tc := range []struct {
name string
err error
short string
long string
}{
{
"privilege",
ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
"This action requires elevated privileges.",
"Claiming a profile requires root.",
},
{
"session held",
ipcauth.SessionHeldError("switching profile"),
"Another user has this machine connected.",
"Switching profile is refused while another user has this machine connected.",
},
{
"not owner",
ipcauth.NotOwnerError("reading the profile configuration"),
"This profile belongs to another user.",
"Reading the profile configuration is refused because the profile it addresses belongs to another user.",
},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.short, got.Short, "Short should be the localised headline for the code")
assert.Contains(t, got.Long, tc.long, "Long should carry the daemon's own sentence")
assert.NotEqual(t, got.Short, got.Long, "a repeated sentence costs the frontend its detail line")
})
}
}
// A reason added daemon-side must still reach the user, losing only the tailored
// presentation. It must not borrow another code's headline: "permission_denied"
// is the sign-in rejection, which has nothing to do with an IPC refusal.
func TestClassifyKeepsTheSentenceForAnUnknownReason(t *testing.T) {
code, known := denialCode("SOMETHING_NEW")
assert.False(t, known, "an unrecognised reason must not claim a tailored headline")
assert.Equal(t, "permission_denied", code)
const summary = "Doing something new is refused for a reason this build predates."
st, err := gstatus.New(gcodes.PermissionDenied, summary).WithDetails(&errdetails.ErrorInfo{
Reason: "SOMETHING_NEW",
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: summary},
})
require.NoError(t, err)
got := errorClassifier{}.classify(st.Err())
require.NotNil(t, got)
assert.Equal(t, summary, got.Short, "the daemon's sentence stands in for the headline")
assert.Equal(t, summary, got.Long)
}
// Only a privilege refusal is answered by offering to elevate. Offering it for
// a session another user holds would be nonsense.
func TestPrivilegeRefusedIsNarrow(t *testing.T) {
assert.True(t, privilegeRefused(ipcauth.PrivilegeError("x", "y")))
assert.False(t, privilegeRefused(ipcauth.SessionHeldError("connecting")))
assert.False(t, privilegeRefused(ipcauth.NotOwnerError("connecting")))
assert.False(t, privilegeRefused(errors.New("connection refused")))
}
+18 -15
View File
@@ -54,11 +54,14 @@ type RenameProfileParams struct {
}
type Profiles struct {
conn DaemonConn
conn DaemonConn
classifier errorClassifier
}
func NewProfiles(conn DaemonConn) *Profiles {
return &Profiles{conn: conn}
// NewProfiles wires up a Profiles service. translator or prefs may be nil, in
// which case classification falls back to the bare error key.
func NewProfiles(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Profiles {
return &Profiles{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
// Username returns the OS username the daemon expects for profile lookups.
@@ -73,11 +76,11 @@ func (s *Profiles) Username() (string, error) {
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
cli, err := s.conn.Client()
if err != nil {
return nil, err
return nil, s.classifier.classify(err)
}
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
if err != nil {
return nil, err
return nil, s.classifier.classify(err)
}
pm := profilemanager.NewProfileManager()
out := make([]Profile, 0, len(resp.GetProfiles()))
@@ -94,11 +97,11 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
cli, err := s.conn.Client()
if err != nil {
return ActiveProfile{}, err
return ActiveProfile{}, s.classifier.classify(err)
}
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
if err != nil {
return ActiveProfile{}, err
return ActiveProfile{}, s.classifier.classify(err)
}
return ActiveProfile{
ID: resp.GetId(),
@@ -114,7 +117,7 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
req := &proto.SwitchProfileRequest{}
if p.ProfileName != "" {
@@ -125,7 +128,7 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
}
resp, err := cli.SwitchProfile(ctx, req)
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetId(), nil
}
@@ -136,14 +139,14 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetId(), nil
}
@@ -151,14 +154,14 @@ func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return err
return s.classifier.classify(err)
}
// The daemon deletes what it owns but runs as root, so it leaves the
@@ -188,7 +191,7 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
Username: p.Username,
@@ -196,7 +199,7 @@ func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, e
NewProfileName: p.NewName,
})
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetOldProfileName(), nil
}
+85
View File
@@ -0,0 +1,85 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// stubProfileDaemon refuses SwitchProfile the way the daemon refuses a caller
// who does not hold the session. The embedded interface is nil, so any other
// call panics rather than passing quietly.
type stubProfileDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubProfileDaemon) SwitchProfile(_ context.Context, _ *proto.SwitchProfileRequest, _ ...grpc.CallOption) (*proto.SwitchProfileResponse, error) {
return nil, d.err
}
// sessionHeldRefusal is the error the daemon raises when another user holds the
// connection, detail and all: see ipcauth.SessionHeldError.
func sessionHeldRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, sessionHeldSummaryText+"\n\nsudo netbird down").
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonSessionHeld,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: sessionHeldSummaryText,
ipcauth.ErrorMetaCommand: "sudo netbird down",
},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
const sessionHeldSummaryText = "Switching profiles is refused while another user has this machine connected."
func profilesRefusingSwitch(t *testing.T) *Profiles {
t.Helper()
// nil translator → Short is the bare "error.<code>" key, which is enough to
// tell a resolved headline from the daemon's own sentence.
return NewProfiles(stubConn{client: &stubProfileDaemon{err: sessionHeldRefusal(t)}}, nil, nil)
}
// A refused switch has to reach the caller as the classified value, since that is
// the only thing carrying the headline and the command the frontend renders.
func TestProfilesSwitchClassifiesRefusal(t *testing.T) {
_, err := profilesRefusingSwitch(t).Switch(context.Background(), ProfileRef{ProfileName: "work"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "Switch must return the classified error, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, sessionHeldSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
}
// The switcher used to wrap this in fmt.Errorf, which left the Wails binding
// nothing to marshal and put the raw "switch profile %q: rpc error: ..." string
// in front of the user instead of the headline and the copyable command.
func TestProfileSwitcherReturnsClassifiedRefusal(t *testing.T) {
switcher := NewProfileSwitcher(profilesRefusingSwitch(t), nil, nil)
err := switcher.SwitchActive(context.Background(), ProfileRef{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "the switcher must pass the classified error through, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
assert.Equal(t, "error.session_held", err.Error(),
"no wrapping prefix and no gRPC dump in front of the headline")
}
+6 -3
View File
@@ -4,7 +4,6 @@ package services
import (
"context"
"fmt"
"strings"
log "github.com/sirupsen/logrus"
@@ -68,9 +67,12 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
s.feed.BeginProfileSwitch()
}
// Returned unwrapped: the Wails binding marshals the outermost error, so a
// wrapper replaces the classified headline and the daemon's suggested
// command with a raw gRPC string. The Infof above names the profile.
resolvedID, err := s.profiles.Switch(ctx, p)
if err != nil {
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
return err
}
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
@@ -90,8 +92,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
}
if connect {
// Unwrapped for the same reason as the switch above.
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
return err
}
}
+7 -5
View File
@@ -133,14 +133,16 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
cli, err := s.conn.Client()
if err != nil {
return Config{}, err
return Config{}, s.classifier.classify(err)
}
resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return Config{}, err
// Reading another user's profile is refused here, and the settings
// screen puts the result straight in front of the user.
return Config{}, s.classifier.classify(err)
}
return Config{
ManagementURL: resp.GetManagementUrl(),
@@ -175,7 +177,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return SaveOutcome{}, s.classifier.classify(err)
}
req := &proto.SetConfigRequest{
ProfileName: p.ProfileName,
@@ -207,7 +209,7 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
SshJWTCacheTTL: p.SSHJWTCacheTTL,
}
if _, err := cli.SetConfig(ctx, req); err != nil {
if _, refused := privilegeErrorInfo(err); refused {
if privilegeRefused(err) {
return s.setConfigElevated(ctx, p, req, err)
}
// Classified so the frontend gets the daemon's guidance instead of the
@@ -251,7 +253,7 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return SaveOutcome{}, s.classifier.classify(err)
}
if _, err := cli.SetConfig(ctx, req); err != nil {
return SaveOutcome{}, s.classifier.classify(err)
+63
View File
@@ -0,0 +1,63 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
const notOwnerSummaryText = "Reading the profile configuration is refused because the profile it addresses belongs to another user."
// stubConfigDaemon refuses GetConfig the way the daemon refuses a caller who
// does not own the profile the request names. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubConfigDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubConfigDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return nil, d.err
}
// notOwnerRefusal is the error the gate raises for a profile owned by somebody
// else: see ipcauth.NotOwnerError. It carries no command on purpose — privilege
// is not what the method asked for.
func notOwnerRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, notOwnerSummaryText).
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonNotProfileOwner,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: notOwnerSummaryText},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
// The settings screen reads the config on mount and shows whatever comes back,
// so an unclassified refusal there is a raw gRPC dump in front of the user.
func TestSettingsGetConfigClassifiesRefusal(t *testing.T) {
// nil translator → Short is the bare "error.<code>" key.
settings := NewSettings(stubConn{client: &stubConfigDaemon{err: notOwnerRefusal(t)}}, nil, nil, testDaemonAddr)
_, err := settings.GetConfig(context.Background(), ConfigParams{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "GetConfig must return the classified error, got %T", err)
assert.Equal(t, "not_profile_owner", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, notOwnerSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Empty(t, clientErr.Command, "this refusal has no command to offer")
}
+9 -6
View File
@@ -22,12 +22,15 @@ type UpdateResult struct {
// Update is the Wails-bound facade over the daemon's update RPCs. The state
// machine and push event live in client/ui/updater.
type Update struct {
conn DaemonConn
holder *updater.Holder
conn DaemonConn
holder *updater.Holder
classifier errorClassifier
}
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
return &Update{conn: conn, holder: holder}
// NewUpdate wires up an Update service. translator or prefs may be nil, in
// which case classification falls back to the bare error key.
func NewUpdate(conn DaemonConn, holder *updater.Holder, translator ErrorTranslator, prefs LanguagePreference) *Update {
return &Update{conn: conn, holder: holder, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
func (s *Update) GetState() updater.State {
@@ -52,11 +55,11 @@ func (s *Update) Quit() {
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
cli, err := s.conn.Client()
if err != nil {
return UpdateResult{}, err
return UpdateResult{}, s.classifier.classify(err)
}
resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{})
if err != nil {
return UpdateResult{}, err
return UpdateResult{}, s.classifier.classify(err)
}
return UpdateResult{
Success: resp.GetSuccess(),
+12 -3
View File
@@ -269,7 +269,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
return s
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
// settingsWindowURL is the start URL for a settings window showing tab. The tab
// travels in the URL so the first render already has it. EventSettingsOpen
// reaches the frontend only after it reports ready, by which point a tab that
// reads the daemon config has mounted and sent its read.
func settingsWindowURL(tab string) string {
return "/#/settings?tab=" + url.QueryEscape(tab)
}
func (s *WindowManager) newSettingsWindow(tab string) *application.WebviewWindow {
a := CurrentAppearance()
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: windowSettings,
@@ -282,7 +290,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: WindowBackgroundColour(a),
URL: "/#/settings",
URL: settingsWindowURL(tab),
Mac: AppleMacOSAppearanceOptions(a),
Windows: MicrosoftWindowsAppearanceOptions(a),
Linux: LinuxAppearanceOptions(s.linuxIcon),
@@ -305,7 +313,8 @@ func (s *WindowManager) OpenSettings(tab string) {
target = "general"
}
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
factory := func() *application.WebviewWindow { return s.newSettingsWindow(target) }
s.withWindow(windowSettings, &s.settings, factory, func(w *application.WebviewWindow, _ bool) {
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
+9
View File
@@ -348,3 +348,12 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
// The settings window opens on whichever tab the caller asked for, so a tab that
// does not read the daemon configuration never mounts the one that does.
func TestSettingsWindowURLCarriesTab(t *testing.T) {
require.Equal(t, "/#/settings?tab=profiles", settingsWindowURL("profiles"))
require.Equal(t, "/#/settings?tab=general", settingsWindowURL("general"))
require.Equal(t, "/#/settings?tab=a%2Fb+c", settingsWindowURL("a/b c"),
"a tab name is escaped rather than trusted to be URL-safe")
}