diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7f1359510..8ea1fa607 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -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={} /> + } /> } /> } /> diff --git a/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx b/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx new file mode 100644 index 000000000..dd442368b --- /dev/null +++ b/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx @@ -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(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(() => { + 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 ( + + + + {title} + + {rows.length > 0 && ( +
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ )} + +
+ {t("approval.countdown", { seconds: remaining })} +
+ + + + {showViewOnly && ( + + )} + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index 816dcb71f..b28e05fb0 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -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 && ( + + )} {!features.disableUpdateSettings && ( = { [Tab.Security]: , [Tab.Profiles]: , [Tab.SSH]: , + [Tab.VNC]: , [Tab.Advanced]: , [Tab.Troubleshooting]: , [Tab.About]: , @@ -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, diff --git a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx new file mode 100644 index 000000000..1da4a2c26 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx @@ -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 ( + <> + + setField("serverVncAllowed", v)} + label={t("settings.vnc.server.label")} + helpText={t("settings.vnc.server.help")} + /> + + + + setField("disableVncApproval", !v)} + label={t("settings.vnc.approval.label")} + helpText={t("settings.vnc.approval.help")} + /> + + + ); +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e0d8096d..4e9edb3cf 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -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" } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 42d40ec30..6c510a170 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -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." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 47faee61f..f55b4015c 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -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" } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index be0836e93..12162886c 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "L’opé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" } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b54918364..3da67ce55 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -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" } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 603364fa2..54e0ffb18 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -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" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2ed0a94c5..46415a0fc 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -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" } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 6ba7de8cc..ec8d3fdda 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -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": "Отклонить" } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 609344fc0..2ca9cbf54 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -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": "拒绝" } } diff --git a/client/ui/main.go b/client/ui/main.go index e6b77762c..79b0240cf 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -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)) diff --git a/client/ui/services/approval.go b/client/ui/services/approval.go new file mode 100644 index 000000000..0dbf648af --- /dev/null +++ b/client/ui/services/approval.go @@ -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 +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 1c16795ae..9e1be7d4a 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -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, diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 3316dadaa..4a7e65e4d 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -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 } diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..0855cd58c 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -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)