Port VNC settings and connection-approval prompt to the Wails UI

This commit is contained in:
Viktor Liu
2026-07-12 18:46:41 +02:00
parent dbc7b846b2
commit 02e7c0e5d2
19 changed files with 932 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import "./globals.css";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
import ApprovalDialog from "@/modules/approval/ApprovalDialog.tsx";
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
@@ -48,6 +49,7 @@ Promise.all([
path={"session-expiration"}
element={<SessionExpirationDialog />}
/>
<Route path={"approval"} element={<ApprovalDialog />} />
<Route path={"welcome"} element={<WelcomeDialog />} />
<Route path={"error"} element={<ErrorDialog />} />
</Route>

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { MonitorIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Approval, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 360;
// Fallback window so a missing/unparseable expires_at can't leave the prompt open forever.
const FALLBACK_SECONDS = 13;
// shortFingerprint groups a hex key as XXXX-XXXX-XXXX-XXXX (16 chars). Mirrors the
// daemon's approval.ShortKeyFingerprint so the value matches an out-of-band reference.
function shortFingerprint(hexKey: string): string {
if (hexKey.length < 8) return "";
const src = hexKey.slice(0, 16);
return src.match(/.{1,4}/g)?.join("-") ?? src;
}
type Row = { label: string; value: string; mono?: boolean };
export default function ApprovalDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const [busy, setBusy] = useState(false);
const requestID = params.get("request_id") ?? "";
const kind = params.get("kind") ?? "";
const initiator = params.get("initiator") ?? "";
const peerName = params.get("peer_name") ?? "";
const sourceIP = params.get("source_ip") ?? "";
const username = params.get("username") ?? "";
const peerPubKey = params.get("peer_pubkey") ?? "";
const expiresAt = params.get("expires_at") ?? "";
const deadline = useMemo(() => {
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : Date.now() + FALLBACK_SECONDS * 1000;
}, [expiresAt]);
const title = useMemo(() => {
switch (kind) {
case "vnc":
return t("approval.title.vnc");
case "ssh":
return t("approval.title.ssh");
default:
return t("approval.title.default");
}
}, [kind, t]);
const rows = useMemo<Row[]>(() => {
const out: Row[] = [];
// The display name is dashboard-supplied and not cryptographically
// asserted; the key fingerprint below IS, so show both.
if (initiator) out.push({ label: t("approval.field.user"), value: initiator });
const fp = shortFingerprint(peerPubKey);
if (fp) out.push({ label: t("approval.field.keyFingerprint"), value: fp, mono: true });
if (peerName) out.push({ label: t("approval.field.peer"), value: peerName });
if (sourceIP && sourceIP !== peerName)
out.push({ label: t("approval.field.sourceIp"), value: sourceIP, mono: true });
if (username) out.push({ label: t("approval.field.osUser"), value: username });
return out;
}, [initiator, peerPubKey, peerName, sourceIP, username, t]);
const respond = useCallback(
async (accept: boolean, viewOnly: boolean) => {
if (busy) return;
setBusy(true);
try {
if (requestID) {
await Approval.Respond(requestID, accept, viewOnly);
}
} catch (e) {
console.error("respond approval failed", e);
} finally {
WindowManager.CloseApproval().catch(console.error);
}
},
[busy, requestID],
);
const secondsLeft = () => Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
const [remaining, setRemaining] = useState(secondsLeft);
const closedRef = useRef(false);
useEffect(() => {
const id = globalThis.setInterval(() => {
const left = secondsLeft();
setRemaining(left);
// On the deadline the daemon auto-denies; just close the prompt.
if (left <= 0 && !closedRef.current) {
closedRef.current = true;
WindowManager.CloseApproval().catch(console.error);
}
}, 1000);
return () => globalThis.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deadline]);
const showViewOnly = kind === "vnc";
return (
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-approval-title"}>
<SquareIcon icon={MonitorIcon} />
<DialogHeading id={"nb-approval-title"}>{title}</DialogHeading>
{rows.length > 0 && (
<dl className={"w-full space-y-1 text-left text-sm"}>
{rows.map((row) => (
<div key={row.label} className={"flex justify-between gap-4"}>
<dt className={"shrink-0 text-nb-gray-400"}>{row.label}</dt>
<dd
className={`min-w-0 truncate text-nb-gray-100 ${
row.mono ? "font-mono" : ""
}`}
title={row.value}
>
{row.value}
</dd>
</div>
))}
</dl>
)}
<div className={"text-sm tabular-nums text-nb-gray-400"} aria-live={"polite"}>
{t("approval.countdown", { seconds: remaining })}
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={() => respond(true, false)}
disabled={busy}
>
{t("approval.action.allow")}
</Button>
{showViewOnly && (
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={() => respond(true, true)}
disabled={busy}
>
{t("approval.action.allowViewOnly")}
</Button>
)}
<Button
variant={"danger"}
size={"md"}
className={"w-full"}
onClick={() => respond(false, false)}
disabled={busy}
>
{t("approval.action.deny")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -8,6 +8,7 @@ import {
BoltIcon,
InfoIcon,
LifeBuoyIcon,
MonitorIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
@@ -63,6 +64,13 @@ export const SettingsNavigation = () => {
title={t("settings.tabs.ssh")}
/>
)}
{!features.disableUpdateSettings && (
<VerticalTabs.Trigger
value={"vnc"}
icon={MonitorIcon}
title={t("settings.tabs.vnc")}
/>
)}
{!features.disableUpdateSettings && (
<VerticalTabs.Trigger
value={"advanced"}

View File

@@ -13,6 +13,7 @@ import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsVNC } from "@/modules/settings/SettingsVNC.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
@@ -26,6 +27,7 @@ const enum Tab {
Security = "security",
Profiles = "profiles",
SSH = "ssh",
VNC = "vnc",
Advanced = "advanced",
Troubleshooting = "troubleshooting",
About = "about",
@@ -37,6 +39,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.Security]: <SettingsSecurity />,
[Tab.Profiles]: <ProfilesTab />,
[Tab.SSH]: <SettingsSSH />,
[Tab.VNC]: <SettingsVNC />,
[Tab.Advanced]: <SettingsAdvanced />,
[Tab.Troubleshooting]: <SettingsTroubleshooting />,
[Tab.About]: <SettingsAbout />,
@@ -55,6 +58,7 @@ export const SettingsPage = () => {
[Tab.Security]: editable,
[Tab.Profiles]: !features.disableProfiles,
[Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.VNC]: editable,
[Tab.Advanced]: editable,
[Tab.Troubleshooting]: true,
[Tab.About]: true,

View File

@@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
export function SettingsVNC() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const isVNCServerEnabled = config.serverVncAllowed;
return (
<>
<SectionGroup title={t("settings.vnc.section.server")}>
<FancyToggleSwitch
value={config.serverVncAllowed}
onChange={(v) => setField("serverVncAllowed", v)}
label={t("settings.vnc.server.label")}
helpText={t("settings.vnc.server.help")}
/>
</SectionGroup>
<SectionGroup title={t("settings.vnc.section.approval")} disabled={!isVNCServerEnabled}>
<FancyToggleSwitch
value={!config.disableVncApproval}
onChange={(v) => setField("disableVncApproval", !v)}
label={t("settings.vnc.approval.label")}
helpText={t("settings.vnc.approval.help")}
/>
</SectionGroup>
</>
);
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Server"
},
"settings.vnc.section.approval": {
"message": "Genehmigung"
},
"settings.vnc.server.label": {
"message": "VNC-Server aktivieren"
},
"settings.vnc.server.help": {
"message": "Den NetBird-VNC-Server auf diesem Host ausführen, damit autorisierte Peers den Bildschirm ansehen oder steuern können."
},
"settings.vnc.approval.label": {
"message": "Verbindungsgenehmigung erforderlich"
},
"settings.vnc.approval.help": {
"message": "Auf diesem Host eine Aufforderung anzeigen, die bestätigt werden muss, bevor eine eingehende VNC-Verbindung zugelassen wird."
},
"window.title.approval": {
"message": "Verbindungsanfrage"
},
"approval.title.vnc": {
"message": "VNC-Verbindung zulassen?"
},
"approval.title.ssh": {
"message": "SSH-Verbindung zulassen?"
},
"approval.title.default": {
"message": "Eingehende Verbindung zulassen?"
},
"approval.field.user": {
"message": "Von Benutzer"
},
"approval.field.keyFingerprint": {
"message": "Schlüssel-Fingerabdruck"
},
"approval.field.peer": {
"message": "Über Peer"
},
"approval.field.sourceIp": {
"message": "Quell-IP"
},
"approval.field.osUser": {
"message": "Betriebssystem-Benutzer"
},
"approval.countdown": {
"message": "Automatische Ablehnung in {seconds}s"
},
"approval.action.allow": {
"message": "Zulassen"
},
"approval.action.allowViewOnly": {
"message": "Zulassen (nur ansehen)"
},
"approval.action.deny": {
"message": "Ablehnen"
}
}

View File

@@ -683,6 +683,10 @@
"message": "SSH",
"description": "Settings tab label: SSH. Acronym — keep as-is."
},
"settings.tabs.vnc": {
"message": "VNC",
"description": "Settings tab label: VNC. Acronym — keep as-is."
},
"settings.tabs.advanced": {
"message": "Advanced",
"description": "Settings tab label: Advanced. Keep short."
@@ -951,6 +955,30 @@
"message": "Second(s)",
"description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural."
},
"settings.vnc.section.server": {
"message": "Server",
"description": "Section heading: Server (VNC settings)."
},
"settings.vnc.section.approval": {
"message": "Approval",
"description": "Section heading: Approval (VNC connection approval settings)."
},
"settings.vnc.server.label": {
"message": "Enable VNC Server",
"description": "Toggle label: enable the embedded VNC server."
},
"settings.vnc.server.help": {
"message": "Run the NetBird VNC server on this host so authorized peers can view or control its screen.",
"description": "Helper text for the VNC server toggle."
},
"settings.vnc.approval.label": {
"message": "Require Connection Approval",
"description": "Toggle label: prompt for approval before each inbound VNC connection."
},
"settings.vnc.approval.help": {
"message": "Show a prompt on this host that must be accepted before an incoming VNC connection is allowed.",
"description": "Helper text for the VNC connection-approval toggle."
},
"settings.advanced.section.interface": {
"message": "Interface",
"description": "Section heading: Interface (network-interface settings)."
@@ -1363,6 +1391,58 @@
"message": "Session Expiring",
"description": "OS window-chrome title for the session-expiration window."
},
"window.title.approval": {
"message": "Connection Request",
"description": "OS window-chrome title for the inbound-connection approval window."
},
"approval.title.vnc": {
"message": "Allow VNC Connection?",
"description": "Approval dialog heading for an inbound VNC connection."
},
"approval.title.ssh": {
"message": "Allow SSH Connection?",
"description": "Approval dialog heading for an inbound SSH connection."
},
"approval.title.default": {
"message": "Allow Incoming Connection?",
"description": "Approval dialog heading for an inbound connection of unknown kind."
},
"approval.field.user": {
"message": "From user",
"description": "Approval dialog row label: the initiating user's display name."
},
"approval.field.keyFingerprint": {
"message": "Key fingerprint",
"description": "Approval dialog row label: the connecting peer's cryptographic key fingerprint."
},
"approval.field.peer": {
"message": "Via peer",
"description": "Approval dialog row label: the peer the connection arrives through."
},
"approval.field.sourceIp": {
"message": "Source IP",
"description": "Approval dialog row label: the source IP address of the connection."
},
"approval.field.osUser": {
"message": "OS user",
"description": "Approval dialog row label: the target operating-system user."
},
"approval.countdown": {
"message": "Auto-deny in {seconds}s",
"description": "Approval dialog countdown; {seconds} is the remaining whole seconds before the daemon auto-denies."
},
"approval.action.allow": {
"message": "Allow",
"description": "Approval dialog button: allow the connection."
},
"approval.action.allowViewOnly": {
"message": "Allow (view only)",
"description": "Approval dialog button: allow the connection in view-only mode."
},
"approval.action.deny": {
"message": "Deny",
"description": "Approval dialog button: deny the connection."
},
"window.title.updating": {
"message": "Updating",
"description": "OS window-chrome title for the update / install window."

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "La operación falló."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Servidor"
},
"settings.vnc.section.approval": {
"message": "Aprobación"
},
"settings.vnc.server.label": {
"message": "Habilitar el servidor VNC"
},
"settings.vnc.server.help": {
"message": "Ejecuta el servidor VNC de NetBird en este host para que los peers autorizados puedan ver o controlar su pantalla."
},
"settings.vnc.approval.label": {
"message": "Requerir aprobación de conexión"
},
"settings.vnc.approval.help": {
"message": "Mostrar en este host una solicitud que debe aceptarse antes de permitir una conexión VNC entrante."
},
"window.title.approval": {
"message": "Solicitud de conexión"
},
"approval.title.vnc": {
"message": "¿Permitir la conexión VNC?"
},
"approval.title.ssh": {
"message": "¿Permitir la conexión SSH?"
},
"approval.title.default": {
"message": "¿Permitir la conexión entrante?"
},
"approval.field.user": {
"message": "Del usuario"
},
"approval.field.keyFingerprint": {
"message": "Huella de la clave"
},
"approval.field.peer": {
"message": "A través del peer"
},
"approval.field.sourceIp": {
"message": "IP de origen"
},
"approval.field.osUser": {
"message": "Usuario del SO"
},
"approval.countdown": {
"message": "Rechazo automático en {seconds}s"
},
"approval.action.allow": {
"message": "Permitir"
},
"approval.action.allowViewOnly": {
"message": "Permitir (solo ver)"
},
"approval.action.deny": {
"message": "Denegar"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Serveur"
},
"settings.vnc.section.approval": {
"message": "Approbation"
},
"settings.vnc.server.label": {
"message": "Activer le serveur VNC"
},
"settings.vnc.server.help": {
"message": "Exécuter le serveur VNC de NetBird sur cet hôte afin que les pairs autorisés puissent voir ou contrôler son écran."
},
"settings.vnc.approval.label": {
"message": "Exiger l'approbation des connexions"
},
"settings.vnc.approval.help": {
"message": "Afficher sur cet hôte une invite qui doit être acceptée avant d'autoriser une connexion VNC entrante."
},
"window.title.approval": {
"message": "Demande de connexion"
},
"approval.title.vnc": {
"message": "Autoriser la connexion VNC ?"
},
"approval.title.ssh": {
"message": "Autoriser la connexion SSH ?"
},
"approval.title.default": {
"message": "Autoriser la connexion entrante ?"
},
"approval.field.user": {
"message": "De l'utilisateur"
},
"approval.field.keyFingerprint": {
"message": "Empreinte de clé"
},
"approval.field.peer": {
"message": "Via le pair"
},
"approval.field.sourceIp": {
"message": "IP source"
},
"approval.field.osUser": {
"message": "Utilisateur du système"
},
"approval.countdown": {
"message": "Refus automatique dans {seconds}s"
},
"approval.action.allow": {
"message": "Autoriser"
},
"approval.action.allowViewOnly": {
"message": "Autoriser (lecture seule)"
},
"approval.action.deny": {
"message": "Refuser"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Szerver"
},
"settings.vnc.section.approval": {
"message": "Jóváhagyás"
},
"settings.vnc.server.label": {
"message": "VNC szerver engedélyezése"
},
"settings.vnc.server.help": {
"message": "A NetBird VNC szerver futtatása ezen a gépen, hogy az arra jogosult partnerek megtekinthessék vagy vezérelhessék a képernyőjét."
},
"settings.vnc.approval.label": {
"message": "Kapcsolat jóváhagyásának megkövetelése"
},
"settings.vnc.approval.help": {
"message": "Megerősítést kérő ablak megjelenítése ezen a gépen, amelyet el kell fogadni a bejövő VNC-kapcsolat engedélyezése előtt."
},
"window.title.approval": {
"message": "Kapcsolódási kérés"
},
"approval.title.vnc": {
"message": "Engedélyezi a VNC-kapcsolatot?"
},
"approval.title.ssh": {
"message": "Engedélyezi az SSH-kapcsolatot?"
},
"approval.title.default": {
"message": "Engedélyezi a bejövő kapcsolatot?"
},
"approval.field.user": {
"message": "Felhasználótól"
},
"approval.field.keyFingerprint": {
"message": "Kulcs ujjlenyomata"
},
"approval.field.peer": {
"message": "Partneren keresztül"
},
"approval.field.sourceIp": {
"message": "Forrás IP"
},
"approval.field.osUser": {
"message": "OS-felhasználó"
},
"approval.countdown": {
"message": "Automatikus elutasítás {seconds} mp múlva"
},
"approval.action.allow": {
"message": "Engedélyezés"
},
"approval.action.allowViewOnly": {
"message": "Engedélyezés (csak megtekintés)"
},
"approval.action.deny": {
"message": "Elutasítás"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Server"
},
"settings.vnc.section.approval": {
"message": "Approvazione"
},
"settings.vnc.server.label": {
"message": "Abilita server VNC"
},
"settings.vnc.server.help": {
"message": "Esegui il server VNC di NetBird su questo host in modo che i peer autorizzati possano visualizzarne o controllarne lo schermo."
},
"settings.vnc.approval.label": {
"message": "Richiedi l'approvazione della connessione"
},
"settings.vnc.approval.help": {
"message": "Mostra su questo host una richiesta che deve essere accettata prima di consentire una connessione VNC in entrata."
},
"window.title.approval": {
"message": "Richiesta di connessione"
},
"approval.title.vnc": {
"message": "Consentire la connessione VNC?"
},
"approval.title.ssh": {
"message": "Consentire la connessione SSH?"
},
"approval.title.default": {
"message": "Consentire la connessione in entrata?"
},
"approval.field.user": {
"message": "Dall'utente"
},
"approval.field.keyFingerprint": {
"message": "Impronta della chiave"
},
"approval.field.peer": {
"message": "Tramite peer"
},
"approval.field.sourceIp": {
"message": "IP di origine"
},
"approval.field.osUser": {
"message": "Utente del sistema"
},
"approval.countdown": {
"message": "Rifiuto automatico tra {seconds}s"
},
"approval.action.allow": {
"message": "Consenti"
},
"approval.action.allowViewOnly": {
"message": "Consenti (sola visualizzazione)"
},
"approval.action.deny": {
"message": "Rifiuta"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "A operação falhou."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Servidor"
},
"settings.vnc.section.approval": {
"message": "Aprovação"
},
"settings.vnc.server.label": {
"message": "Ativar servidor VNC"
},
"settings.vnc.server.help": {
"message": "Execute o servidor VNC do NetBird neste host para que os peers autorizados possam ver ou controlar a sua tela."
},
"settings.vnc.approval.label": {
"message": "Exigir aprovação de conexão"
},
"settings.vnc.approval.help": {
"message": "Mostrar neste host um aviso que precisa ser aceito antes de permitir uma conexão VNC de entrada."
},
"window.title.approval": {
"message": "Solicitação de conexão"
},
"approval.title.vnc": {
"message": "Permitir a conexão VNC?"
},
"approval.title.ssh": {
"message": "Permitir a conexão SSH?"
},
"approval.title.default": {
"message": "Permitir a conexão de entrada?"
},
"approval.field.user": {
"message": "Do usuário"
},
"approval.field.keyFingerprint": {
"message": "Impressão digital da chave"
},
"approval.field.peer": {
"message": "Via peer"
},
"approval.field.sourceIp": {
"message": "IP de origem"
},
"approval.field.osUser": {
"message": "Usuário do SO"
},
"approval.countdown": {
"message": "Negação automática em {seconds}s"
},
"approval.action.allow": {
"message": "Permitir"
},
"approval.action.allowViewOnly": {
"message": "Permitir (somente visualização)"
},
"approval.action.deny": {
"message": "Negar"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "Сервер"
},
"settings.vnc.section.approval": {
"message": "Подтверждение"
},
"settings.vnc.server.label": {
"message": "Включить VNC-сервер"
},
"settings.vnc.server.help": {
"message": "Запустить VNC-сервер NetBird на этом хосте, чтобы авторизованные пиры могли просматривать его экран или управлять им."
},
"settings.vnc.approval.label": {
"message": "Требовать подтверждение подключения"
},
"settings.vnc.approval.help": {
"message": "Показывать на этом хосте запрос, который нужно принять перед разрешением входящего VNC-подключения."
},
"window.title.approval": {
"message": "Запрос на подключение"
},
"approval.title.vnc": {
"message": "Разрешить VNC-подключение?"
},
"approval.title.ssh": {
"message": "Разрешить SSH-подключение?"
},
"approval.title.default": {
"message": "Разрешить входящее подключение?"
},
"approval.field.user": {
"message": "От пользователя"
},
"approval.field.keyFingerprint": {
"message": "Отпечаток ключа"
},
"approval.field.peer": {
"message": "Через пир"
},
"approval.field.sourceIp": {
"message": "IP-адрес источника"
},
"approval.field.osUser": {
"message": "Пользователь ОС"
},
"approval.countdown": {
"message": "Автоотклонение через {seconds} с"
},
"approval.action.allow": {
"message": "Разрешить"
},
"approval.action.allowViewOnly": {
"message": "Разрешить (только просмотр)"
},
"approval.action.deny": {
"message": "Отклонить"
}
}

View File

@@ -1321,5 +1321,65 @@
},
"error.unknown": {
"message": "操作失败。"
},
"settings.tabs.vnc": {
"message": "VNC"
},
"settings.vnc.section.server": {
"message": "服务器"
},
"settings.vnc.section.approval": {
"message": "批准"
},
"settings.vnc.server.label": {
"message": "启用 VNC 服务器"
},
"settings.vnc.server.help": {
"message": "在此主机上运行 NetBird VNC 服务器,以便授权的对端可以查看或控制其屏幕。"
},
"settings.vnc.approval.label": {
"message": "要求连接批准"
},
"settings.vnc.approval.help": {
"message": "在此主机上显示一个提示,必须先接受该提示才能允许传入的 VNC 连接。"
},
"window.title.approval": {
"message": "连接请求"
},
"approval.title.vnc": {
"message": "允许 VNC 连接?"
},
"approval.title.ssh": {
"message": "允许 SSH 连接?"
},
"approval.title.default": {
"message": "允许传入连接?"
},
"approval.field.user": {
"message": "来自用户"
},
"approval.field.keyFingerprint": {
"message": "密钥指纹"
},
"approval.field.peer": {
"message": "经由对端"
},
"approval.field.sourceIp": {
"message": "源 IP"
},
"approval.field.osUser": {
"message": "操作系统用户"
},
"approval.countdown": {
"message": "{seconds} 秒后自动拒绝"
},
"approval.action.allow": {
"message": "允许"
},
"approval.action.allowViewOnly": {
"message": "允许(仅查看)"
},
"approval.action.deny": {
"message": "拒绝"
}
}

View File

@@ -318,6 +318,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.settings))
app.RegisterService(application.NewService(s.networks))
app.RegisterService(application.NewService(services.NewForwarding(conn)))
app.RegisterService(application.NewService(services.NewApproval(conn)))
app.RegisterService(application.NewService(s.profiles))
app.RegisterService(application.NewService(services.NewDebug(conn)))
app.RegisterService(application.NewService(s.update))

View File

@@ -0,0 +1,36 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"github.com/netbirdio/netbird/client/proto"
)
// Approval forwards the user's decision on a pending inbound-connection
// approval prompt to the daemon. The daemon pushes the prompt as a SystemEvent
// with category APPROVAL; the dialog calls Respond with the same request id to
// unblock whichever subsystem (VNC, SSH, ...) is waiting.
type Approval struct {
conn DaemonConn
}
func NewApproval(conn DaemonConn) *Approval {
return &Approval{conn: conn}
}
// Respond delivers the accept/deny decision for requestID. viewOnly is only
// meaningful when accept is true and the subsystem supports a read-only grant.
func (a *Approval) Respond(ctx context.Context, requestID string, accept, viewOnly bool) error {
cli, err := a.conn.Client()
if err != nil {
return err
}
_, err = cli.RespondApproval(ctx, &proto.RespondApprovalRequest{
RequestId: requestID,
Accept: accept,
ViewOnly: viewOnly,
})
return err
}

View File

@@ -24,7 +24,7 @@ type MDMFields struct {
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView bool `json:"disableAdvancedView"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct {
@@ -54,6 +54,8 @@ type Config struct {
MTU int64 `json:"mtu"`
DisableAutoConnect bool `json:"disableAutoConnect"`
ServerSSHAllowed bool `json:"serverSshAllowed"`
ServerVNCAllowed bool `json:"serverVncAllowed"`
DisableVNCApproval bool `json:"disableVncApproval"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableNotifications bool `json:"disableNotifications"`
@@ -85,6 +87,8 @@ type SetConfigParams struct {
PreSharedKey *string `json:"preSharedKey,omitempty"`
DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"`
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
ServerVNCAllowed *bool `json:"serverVncAllowed,omitempty"`
DisableVNCApproval *bool `json:"disableVncApproval,omitempty"`
RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"`
RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"`
DisableNotifications *bool `json:"disableNotifications,omitempty"`
@@ -135,6 +139,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
MTU: resp.GetMtu(),
DisableAutoConnect: resp.GetDisableAutoConnect(),
ServerSSHAllowed: resp.GetServerSSHAllowed(),
ServerVNCAllowed: resp.GetServerVNCAllowed(),
DisableVNCApproval: resp.GetDisableVNCApproval(),
RosenpassEnabled: resp.GetRosenpassEnabled(),
RosenpassPermissive: resp.GetRosenpassPermissive(),
DisableNotifications: resp.GetDisableNotifications(),
@@ -170,6 +176,8 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
OptionalPreSharedKey: p.PreSharedKey,
DisableAutoConnect: p.DisableAutoConnect,
ServerSSHAllowed: p.ServerSSHAllowed,
ServerVNCAllowed: p.ServerVNCAllowed,
DisableVNCApproval: p.DisableVNCApproval,
RosenpassEnabled: p.RosenpassEnabled,
RosenpassPermissive: p.RosenpassPermissive,
DisableNotifications: p.DisableNotifications,

View File

@@ -106,6 +106,7 @@ type WindowManager struct {
settings *application.WebviewWindow
browserLogin *application.WebviewWindow
sessionExpiration *application.WebviewWindow
approval *application.WebviewWindow
installProgress *application.WebviewWindow
welcome *application.WebviewWindow
errorDialog *application.WebviewWindow
@@ -279,6 +280,58 @@ func (s *WindowManager) CloseSessionExpiration() {
}
}
// ApprovalRequest carries the daemon-supplied metadata for an inbound-connection
// approval prompt to the dialog window as query params. Kind, RequestID and
// ExpiresAt are daemon-issued; the rest are remote-influenced and shown so the
// user can vet who is connecting.
type ApprovalRequest struct {
RequestID string
Kind string
Initiator string
PeerName string
SourceIP string
Username string
PeerPubKey string
ExpiresAt string
}
// OpenApproval shows the inbound-connection approval prompt on the cursor's
// display. Singleton, destroyed on close: a second request replaces the window,
// and the superseded request auto-denies on the daemon's deadline.
func (s *WindowManager) OpenApproval(req ApprovalRequest) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := approvalDialogURL(req)
if s.approval == nil {
opts := DialogWindowOptions("approval", s.title("window.title.approval"), startURL, s.linuxIcon)
opts.Height = 380
opts.Screen = s.getScreenBasedOnCursorPosition()
opts.InitialPosition = application.WindowCentered
s.approval = s.app.Window.NewWithOptions(opts)
s.approval.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.approval = nil
s.mu.Unlock()
})
s.centerOnCursorScreen(s.approval)
return
}
s.approval.SetURL(startURL)
s.centerOnCursorScreen(s.approval)
s.approval.Show()
s.approval.Focus()
}
func (s *WindowManager) CloseApproval() {
s.mu.Lock()
w := s.approval
s.approval = nil
s.mu.Unlock()
if w != nil {
w.Close()
}
}
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
func (s *WindowManager) OpenInstallProgress(version string) {
@@ -572,5 +625,30 @@ func errorDialogURL(title, message string) string {
return startURL
}
// approvalDialogURL builds the approval window's start URL with the request
// metadata as escaped query params. Empty fields are omitted so the dialog
// renders only the rows it has values for.
func approvalDialogURL(req ApprovalRequest) string {
q := url.Values{}
set := func(k, v string) {
if v != "" {
q.Set(k, v)
}
}
set("request_id", req.RequestID)
set("kind", req.Kind)
set("initiator", req.Initiator)
set("peer_name", req.PeerName)
set("source_ip", req.SourceIP)
set("username", req.Username)
set("peer_pubkey", req.PeerPubKey)
set("expires_at", req.ExpiresAt)
startURL := "/#/dialog/approval"
if enc := q.Encode(); enc != "" {
startURL += "?" + enc
}
return startURL
}
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
func u32ptr(v uint32) *uint32 { return &v }

View File

@@ -42,6 +42,14 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
}
return
}
// Inbound-connection approval prompts open a dedicated dialog instead of a
// toast. Handle before the message gate: the daemon auto-denies on its
// deadline, so a missing WindowManager fails closed.
if se.Category == "approval" {
t.openApproval(se)
return
}
// Session-warning and deadline-rejected events build their body locally from
// metadata; every other event needs a UserMessage.
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
@@ -93,6 +101,30 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
}
// openApproval opens the inbound-connection approval dialog from an APPROVAL
// SystemEvent. request_id is daemon-issued; without it the prompt can't be
// answered, so it's dropped and the daemon auto-denies on its deadline.
func (t *Tray) openApproval(se services.SystemEvent) {
if t.svc.WindowManager == nil {
return
}
requestID := se.Metadata["request_id"]
if requestID == "" {
log.Warnf("approval event missing request_id: %v", se.Metadata)
return
}
t.svc.WindowManager.OpenApproval(services.ApprovalRequest{
RequestID: requestID,
Kind: se.Metadata["kind"],
Initiator: se.Metadata["initiator"],
PeerName: se.Metadata["peer_name"],
SourceIP: se.Metadata["source_ip"],
Username: se.Metadata["username"],
PeerPubKey: se.Metadata["peer_pubkey"],
ExpiresAt: se.Metadata["expires_at"],
})
}
// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication".
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)