diff --git a/.github/workflows/build-macos.yaml b/.github/workflows/build-macos.yaml index 41cca4495..88d64f604 100644 --- a/.github/workflows/build-macos.yaml +++ b/.github/workflows/build-macos.yaml @@ -41,7 +41,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - uses: pnpm/action-setup@v6 with: diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 01109ecd8..8c33f3a9f 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -47,7 +47,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - name: Install pnpm uses: pnpm/action-setup@v6 @@ -133,7 +133,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - name: Install pnpm uses: pnpm/action-setup@v6 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 575c9e160..1fa1ea547 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - uses: pnpm/action-setup@v6 with: diff --git a/.github/workflows/release-macos.yaml b/.github/workflows/release-macos.yaml index 16250016c..d45d53fad 100644 --- a/.github/workflows/release-macos.yaml +++ b/.github/workflows/release-macos.yaml @@ -34,7 +34,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - uses: pnpm/action-setup@v6 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9c316c5e0..172bf229c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -64,7 +64,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - name: Install pnpm uses: pnpm/action-setup@v6 @@ -181,7 +181,7 @@ jobs: echo "DEFGUARD_CLIENT_BUILD_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} - uses: actions/setup-node@v6 with: - node-version: 26 + node-version-file: new-ui/.nvmrc - uses: pnpm/action-setup@v6 with: diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2f5838ac5..000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v20.5 diff --git a/flake.lock b/flake.lock index 5409c70de..35dd08b55 100644 --- a/flake.lock +++ b/flake.lock @@ -14,11 +14,11 @@ }, "crane": { "locked": { - "lastModified": 1781825982, - "narHash": "sha256-SlXKwIRIhrOSAcTjCB3ftPLzJWZStQIPS7J1FlZPnKk=", + "lastModified": 1785284101, + "narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=", "owner": "ipetkov", "repo": "crane", - "rev": "469fd08d0bcf6926321fa973c6777fbc87785dd7", + "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", "type": "github" }, "original": { @@ -59,11 +59,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782636521, - "narHash": "sha256-Yf2TcFLw0r574fP/5eUN6N8el70HbYNdbDeeHpnK2eQ=", - "rev": "e1c1b84752fb0897897380a3cae9dc7fcab91ca3", + "lastModified": 1785141334, + "narHash": "sha256-jIYqF10/p99GhQYHFnT7XgCRWavkL4Iie1DEyIIZSc0=", + "rev": "38a4887411571457d700c51c64a6e49ead2ed5ab", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1023798.e1c1b84752fb/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1042399.38a488741157/nixexprs.tar.xz" }, "original": { "id": "nixpkgs", @@ -114,11 +114,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1782703273, - "narHash": "sha256-WJPfr9EAWVIuTMyb/ilHKUYlg/RNa0xrNiWR+p1iHUg=", + "lastModified": 1785302874, + "narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "5106a604b3d67cffe8eb51a8bd9f04e607f0d31d", + "rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38", "type": "github" }, "original": { diff --git a/new-ui/.nvmrc b/new-ui/.nvmrc index a682cfb97..6f4247a62 100644 --- a/new-ui/.nvmrc +++ b/new-ui/.nvmrc @@ -1 +1 @@ -v25 +26 diff --git a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx index 175abf6ea..c182bced3 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx @@ -9,6 +9,7 @@ import { useAppData } from '../../../../shared/providers/AppDataContext'; import { getInstancesQueryOptions, getTunnelsQueryOptions, + tunnelsDisabled, } from '../../../../shared/rust-api/query'; import type { OverviewViewSelection } from '../../../../shared/rust-api/types'; import { isPresent } from '../../../../shared/utils/isPresent'; @@ -35,17 +36,22 @@ export const InstanceSwitcher = () => { })), }; - const tunnelGroup: SelectOptionGroup = { - key: 'tunnels', - label: 'Tunnels', - options: tunnels.map((tunnel) => ({ - key: `tunnel-${tunnel.id ?? tunnel.name}`, - label: tunnel.name, - value: { kind: 'tunnel', id: tunnel.id }, - })), - }; + const tunnelGroup: SelectOptionGroup | undefined = + !tunnelsDisabled(instances ?? []) && tunnels.length > 0 + ? { + key: 'tunnels', + label: 'Tunnels', + options: tunnels.map((tunnel) => ({ + key: `tunnel-${tunnel.id ?? tunnel.name}`, + label: tunnel.name, + value: { kind: 'tunnel', id: tunnel.id }, + })), + } + : undefined; - return [instanceGroup, tunnelGroup]; + const result: SelectOptionGroup[] = [instanceGroup]; + if (tunnelGroup) result.push(tunnelGroup); + return result; }, [instances, tunnels]); const totalOptions = useMemo( diff --git a/new-ui/src/pages/full/AddPage/AddPage.tsx b/new-ui/src/pages/full/AddPage/AddPage.tsx index f23fe7efc..e7e0baf00 100644 --- a/new-ui/src/pages/full/AddPage/AddPage.tsx +++ b/new-ui/src/pages/full/AddPage/AddPage.tsx @@ -1,12 +1,18 @@ import './style.scss'; +import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { FullPageTitle } from '../../../shared/components/FullPageTitle/FullPageTitle'; import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; +import { + getInstancesQueryOptions, + tunnelsDisabled, +} from '../../../shared/rust-api/query'; import { ThemeSpacing } from '../../../shared/types'; import { AddCard } from './components/AddCard/AddCard'; export const AddPage = () => { const navigate = useNavigate(); + const { data: instances } = useQuery(getInstancesQueryOptions); return ( @@ -22,17 +28,19 @@ export const AddPage = () => { actionText="Add instance" description={`Establish a secure connection to your Defguard instance effortlessly by configuring it with a single token—no manual setup.`} /> - { - navigate({ - to: '/full/add/tunnel', - }); - }} - title="Add WireGuard Tunnel" - actionText="Add tunnel" - description={`Add and configure a WireGuard tunnel to securely route traffic through an encrypted connection using predefined configuration.`} - /> + {!tunnelsDisabled(instances ?? []) && ( + { + navigate({ + to: '/full/add/tunnel', + }); + }} + title="Add WireGuard Tunnel" + actionText="Add tunnel" + description={`Add and configure a WireGuard tunnel to securely route traffic through an encrypted connection using predefined configuration.`} + /> + )} ); diff --git a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx index 1876b814c..c7962ba6f 100644 --- a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx +++ b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx @@ -8,7 +8,10 @@ import { ScrollContainer } from '../../../shared/components/ScrollContainer/Scro import { SizedBox } from '../../../shared/components/SizedBox/SizedBox'; import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; import { useAppData } from '../../../shared/providers/AppDataContext'; -import { getLocationsQueryOptions } from '../../../shared/rust-api/query'; +import { + getLocationsQueryOptions, + tunnelsDisabled, +} from '../../../shared/rust-api/query'; import type { InstanceInfo } from '../../../shared/rust-api/types'; import { ThemeSpacing } from '../../../shared/types'; import { isPresent } from '../../../shared/utils/isPresent'; @@ -59,7 +62,10 @@ export const OverviewPage = () => {
- +
{
- + {!tunnelsDisabled(instances) && } ); diff --git a/new-ui/src/routes/full/_default/add/tunnel.tsx b/new-ui/src/routes/full/_default/add/tunnel.tsx index 0950e243c..51aa88cac 100644 --- a/new-ui/src/routes/full/_default/add/tunnel.tsx +++ b/new-ui/src/routes/full/_default/add/tunnel.tsx @@ -1,6 +1,16 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect } from '@tanstack/react-router'; import { AddTunnelPage } from '../../../../pages/full/AddTunnelPage/AddTunnelPage'; +import { + getInstancesQueryOptions, + tunnelsDisabled, +} from '../../../../shared/rust-api/query'; export const Route = createFileRoute('/full/_default/add/tunnel')({ + beforeLoad: async ({ context }) => { + const instances = await context.queryClient.ensureQueryData(getInstancesQueryOptions); + if (tunnelsDisabled(instances)) { + throw redirect({ to: '/full/overview' }); + } + }, component: AddTunnelPage, }); diff --git a/new-ui/src/routes/full/tunnel-wizard.tsx b/new-ui/src/routes/full/tunnel-wizard.tsx index 6a5e7f51e..c49963b20 100644 --- a/new-ui/src/routes/full/tunnel-wizard.tsx +++ b/new-ui/src/routes/full/tunnel-wizard.tsx @@ -1,6 +1,13 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect } from '@tanstack/react-router'; import { TunnelWizardPage } from '../../pages/full/TunnelWizardPage/TunnelWizardPage'; +import { getInstancesQueryOptions, tunnelsDisabled } from '../../shared/rust-api/query'; export const Route = createFileRoute('/full/tunnel-wizard')({ + beforeLoad: async ({ context }) => { + const instances = await context.queryClient.ensureQueryData(getInstancesQueryOptions); + if (tunnelsDisabled(instances)) { + throw redirect({ to: '/full/overview' }); + } + }, component: TunnelWizardPage, }); diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx index ee8edd0f2..9891a6fbc 100644 --- a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '../../../rust-api/api'; -import { LocationMfaMode } from '../../../rust-api/types'; +import { shouldStartMfa } from '../../../utils/mfa'; import { parseConnectError } from '../api/connectError'; import { useLocationCardContext } from '../context/context'; import { LocationCardViews } from '../context/types'; @@ -46,7 +46,7 @@ export const LocationCardConnectButton = () => { connectionType: location.connection_type, locationId: location.id, }); - } else if (location.location_mfa_mode !== LocationMfaMode.Disabled) { + } else if (shouldStartMfa(location)) { startMfa(); } else { connect({ diff --git a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx index a3aa5cf59..b0d2d9891 100644 --- a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx +++ b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx @@ -12,9 +12,10 @@ import { useConnectModal } from '../../../pages/full/OverviewPage/components/Con import { api } from '../../rust-api/api'; import { getAppConfigQueryOptions } from '../../rust-api/query'; import type { InstanceInfo, LocationInfo } from '../../rust-api/types'; -import { LocationMfaMode, MfaMethod } from '../../rust-api/types'; +import { MfaMethod } from '../../rust-api/types'; import { ThemeSpacing } from '../../types'; import { isPresent } from '../../utils/isPresent'; +import { shouldStartMfa } from '../../utils/mfa'; import { Divider } from '../Divider/Divider'; import { parseConnectError } from '../LocationCard/api/connectError'; import { ConnectButton } from '../LocationCard/components/ConnectButton/ConnectButton'; @@ -78,7 +79,7 @@ export const OverviewLocationCard = ({ location, instance }: Props) => { return; } - if (location.location_mfa_mode !== LocationMfaMode.Disabled) { + if (shouldStartMfa(location)) { useConnectModal.getState().open({ view: mfaMethodToConnectModalView(location.mfa_method ?? MfaMethod.Totp), location, diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx index 1ba84d8bb..d6d8a46c5 100644 --- a/new-ui/src/shared/providers/TauriEventProvider.tsx +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -17,6 +17,7 @@ import { type LocationInfo, MfaMethod, TauriEvent, + type TunnelsDisabledPayload, } from '../rust-api/types'; import { useAppStore } from '../store/useAppStore'; import { decideLocationMfaMethod } from '../utils/decideLocationMfaMethod'; @@ -162,6 +163,18 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { listen(TauriEvent.SessionStateChanged, () => { void queryClient.invalidateQueries({ queryKey: ['session-state'] }); }), + + listen(TauriEvent.TunnelsDisabled, (event) => { + void debug(`UI Received event TunnelsDisabled: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + }), + + listen(TauriEvent.TunnelsEnabled, () => { + void debug('UI Received event TunnelsEnabled'); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + }), ]); return () => { diff --git a/new-ui/src/shared/rust-api/query.ts b/new-ui/src/shared/rust-api/query.ts index eaf79d39a..dbf02caba 100644 --- a/new-ui/src/shared/rust-api/query.ts +++ b/new-ui/src/shared/rust-api/query.ts @@ -2,7 +2,19 @@ import { queryOptions, skipToken } from '@tanstack/react-query'; import { isPresent } from '../utils/isPresent'; import { api } from './api'; -import type { ConnectionArgs, LocationDetailsArgs, StatsArgs } from './types'; +import type { + ConnectionArgs, + InstanceInfo, + LocationDetailsArgs, + StatsArgs, +} from './types'; + +/** + * Single source of truth for the OR-across-instances "tunnels disabled" rule: + * tunnels are disabled when any enrolled instance has the flag set. + */ +export const tunnelsDisabled = (instances: InstanceInfo[]): boolean => + instances.some((i) => i.disable_tunnels); export const getAllActiveConnectionQueryOptions = queryOptions({ queryKey: ['alive-connections'] as const, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index dd3dd49f5..f6fc68fd0 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -166,6 +166,8 @@ export const TauriEvent = { MfaOpenIdError: 'mfa-openid-error', MfaMobileComplete: 'mfa-mobile-complete', MfaMobileError: 'mfa-mobile-error', + TunnelsDisabled: 'tunnel-disabled-by-policy', + TunnelsEnabled: 'tunnel-enabled-by-policy', } as const; export type TauriEventValue = (typeof TauriEvent)[keyof typeof TauriEvent]; @@ -190,6 +192,11 @@ export type AddInstanceEventPayload = { url: string; }; +/** Payload for the `tunnel-disabled-by-policy` event. Mirrors `TunnelsDisabled` in events.rs. */ +export type TunnelsDisabledPayload = { + names: string[]; +}; + export type ActiveConnectionSummary = { id: number; name: string; @@ -222,6 +229,7 @@ export type InstanceInfo = { pubkey: string; client_traffic_policy: ClientTrafficPolicy; enterprise_enabled: boolean; + disable_tunnels: boolean; openid_display_name: string | null; }; diff --git a/new-ui/src/shared/utils/mfa.ts b/new-ui/src/shared/utils/mfa.ts index 9179ce26f..c29b07d6c 100644 --- a/new-ui/src/shared/utils/mfa.ts +++ b/new-ui/src/shared/utils/mfa.ts @@ -1,4 +1,10 @@ -import { MfaMethod, type MfaMethodValue } from '../rust-api/types'; +import { + ConnectionType, + type LocationInfo, + LocationMfaMode, + MfaMethod, + type MfaMethodValue, +} from '../rust-api/types'; const mfaMethodLabels: Record = { [MfaMethod.Email]: 'Email', @@ -19,3 +25,13 @@ export const mfaMethodApiValues: Record = { }; export const mfaToApi = (factor: MfaMethodValue): string => mfaMethodApiValues[factor]; + +/** + * Whether connecting this location should trigger the MFA flow: only for + * server-managed locations (never bare tunnels) that have MFA enabled. + */ +export const shouldStartMfa = ( + location: Pick, +): boolean => + location.connection_type !== ConnectionType.Tunnel && + location.location_mfa_mode !== LocationMfaMode.Disabled; diff --git a/nix/package.nix b/nix/package.nix index 9b5420928..6099188da 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -20,7 +20,7 @@ categories = ["Network" "Security"]; }; - pnpm = pkgs.pnpm_11; + inherit (import ./versions.nix pkgs) nodejs pnpm; buildInputs = with pkgs; [ at-spi2-atk @@ -89,7 +89,7 @@ pname = "defguard-client-new-ui"; inherit version pnpm; src = ../new-ui; - fetcherVersion = 3; + fetcherVersion = 4; hash = "sha256-MGwjt5tV4Uhn0VvEouAij6ldgLR7Qj47TzbUVOTtlx8="; }; @@ -98,7 +98,7 @@ pname = "defguard-client-new-ui"; inherit version; src = ../new-ui; - nativeBuildInputs = [pkgs.nodejs_24 pnpm pnpmConfigHook]; + nativeBuildInputs = [nodejs pnpm pnpmConfigHook]; pnpmDeps = newUiPnpmDeps; buildPhase = '' runHook preBuild diff --git a/nix/shell.nix b/nix/shell.nix index 428c640c0..f3d5f7fa3 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -26,6 +26,8 @@ --config imports_granularity=Crate,group_imports=StdExternalCrate ''; + inherit (import ./versions.nix pkgs) nodejs pnpm; + craneLib = crane.mkLib pkgs; defguard-client = pkgs.callPackage ./package.nix { @@ -55,6 +57,8 @@ in desktop-file-utils xdg-utils just + nodejs + pnpm ]; shellHook = with pkgs; '' diff --git a/nix/versions.nix b/nix/versions.nix new file mode 100644 index 000000000..37e1357cf --- /dev/null +++ b/nix/versions.nix @@ -0,0 +1,16 @@ +# Single source of truth for the Node.js + pnpm versions used by both the +# Nix build (newUiDist in package.nix) and the dev shell (shell.nix), so the +# two can't drift. +# +# Node is derived from new-ui/.nvmrc - the same file nvm/fnm and CI read - so +# non-Nix developers, CI, and Nix all track one version. .nvmrc holds the +# major (e.g. "26"); Nix maps it to the matching nixpkgs attribute. The +# concrete patch version still comes from flake.lock's nixpkgs pin. +pkgs: let + nodeMajor = builtins.head ( + builtins.match "[^0-9]*([0-9]+).*" (builtins.readFile ../new-ui/.nvmrc) + ); +in { + nodejs = pkgs."nodejs_${nodeMajor}"; + pnpm = pkgs.pnpm_11; +} diff --git a/src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json b/src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json similarity index 83% rename from src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json rename to src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json index 13579fc7c..730494fa0 100644 --- a/src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json +++ b/src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, openid_display_name FROM instance ORDER BY name ASC;", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance ORDER BY name ASC;", "describe": { "columns": [ { @@ -49,8 +49,13 @@ "type_info": "Bool" }, { - "name": "openid_display_name", + "name": "disable_tunnels", "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ true, false, false, + false, true ] }, - "hash": "2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96" + "hash": "76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee" } diff --git a/src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json b/src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json similarity index 55% rename from src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json rename to src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json index 0f1dbfc0f..f2b6d853e 100644 --- a/src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json +++ b/src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, client_traffic_policy = $6, enterprise_enabled = $7, token = $8, openid_display_name = $9 WHERE id = $10;", + "query": "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, client_traffic_policy = $6, enterprise_enabled = $7, disable_tunnels = $8, token = $9, openid_display_name = $10 WHERE id = $11;", "describe": { "columns": [], "parameters": { - "Right": 10 + "Right": 11 }, "nullable": [] }, - "hash": "0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d" + "hash": "8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185" } diff --git a/src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json b/src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json similarity index 83% rename from src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json rename to src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json index 3b65dac09..e511e531e 100644 --- a/src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json +++ b/src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, openid_display_name FROM instance WHERE id = $1;", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE id = $1;", "describe": { "columns": [ { @@ -49,8 +49,13 @@ "type_info": "Bool" }, { - "name": "openid_display_name", + "name": "disable_tunnels", "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ true, false, false, + false, true ] }, - "hash": "7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1" + "hash": "c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0" } diff --git a/src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json b/src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json similarity index 55% rename from src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json rename to src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json index ec67166eb..28ce0735d 100644 --- a/src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json +++ b/src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO instance (name, uuid, url, proxy_url, username, token, client_traffic_policy , enterprise_enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id;", + "query": "INSERT INTO instance (name, uuid, url, proxy_url, username, token, client_traffic_policy , enterprise_enabled, disable_tunnels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id;", "describe": { "columns": [ { @@ -10,11 +10,11 @@ } ], "parameters": { - "Right": 8 + "Right": 9 }, "nullable": [ false ] }, - "hash": "c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3" + "hash": "d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d" } diff --git a/src-tauri/.sqlx/query-157a0344c45c1b0567e8815c3f7b3667b753fb0b4b4c10d9d497b8f403ccb89d.json b/src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json similarity index 83% rename from src-tauri/.sqlx/query-157a0344c45c1b0567e8815c3f7b3667b753fb0b4b4c10d9d497b8f403ccb89d.json rename to src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json index 67d032fff..e93482981 100644 --- a/src-tauri/.sqlx/query-157a0344c45c1b0567e8815c3f7b3667b753fb0b4b4c10d9d497b8f403ccb89d.json +++ b/src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, openid_display_name FROM instance WHERE name = $1;", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE name = $1;", "describe": { "columns": [ { @@ -49,8 +49,13 @@ "type_info": "Bool" }, { - "name": "openid_display_name", + "name": "disable_tunnels", "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ true, false, false, + false, true ] }, - "hash": "157a0344c45c1b0567e8815c3f7b3667b753fb0b4b4c10d9d497b8f403ccb89d" + "hash": "eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484" } diff --git a/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json b/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json new file mode 100644 index 000000000..734a9188d --- /dev/null +++ b/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT disable_tunnels as \"disable_tunnels!\" FROM instance", + "describe": { + "columns": [ + { + "name": "disable_tunnels!", + "ordinal": 0, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e" +} diff --git a/src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json b/src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json similarity index 79% rename from src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json rename to src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json index 79b463d4e..b75001f10 100644 --- a/src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json +++ b/src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, client_traffic_policy, enterprise_enabled, openid_display_name FROM instance WHERE token IS NOT NULL ORDER BY name ASC;", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE token IS NOT NULL ORDER BY name ASC;", "describe": { "columns": [ { @@ -49,8 +49,13 @@ "type_info": "Bool" }, { - "name": "openid_display_name", + "name": "disable_tunnels", "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ true, false, false, + false, true ] }, - "hash": "3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727" + "hash": "fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408" } diff --git a/src-tauri/client-cli/src/commands/connect.rs b/src-tauri/client-cli/src/commands/connect.rs index 8bc847662..72e16d5cc 100644 --- a/src-tauri/client-cli/src/commands/connect.rs +++ b/src-tauri/client-cli/src/commands/connect.rs @@ -53,6 +53,10 @@ pub async fn handle( let target = resolve_connect_target(&spec, &state.pool).await?; + if matches!(&target, ResolvedTarget::Tunnel(_)) { + Instance::ensure_tunnels_enabled(&state.pool).await?; + } + // Idempotency: if the target is already connected, report and exit 0. let (target_id, target_connection_type, target_name) = match &target { ResolvedTarget::Location(loc) => (loc.id, ConnectionType::Location, loc.name.as_str()), diff --git a/src-tauri/client-cli/src/commands/instance.rs b/src-tauri/client-cli/src/commands/instance.rs index 8b5415d90..7abe6c048 100644 --- a/src-tauri/client-cli/src/commands/instance.rs +++ b/src-tauri/client-cli/src/commands/instance.rs @@ -140,6 +140,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } diff --git a/src-tauri/client-cli/src/commands/list.rs b/src-tauri/client-cli/src/commands/list.rs index 3666cce1a..3d3eb2f32 100644 --- a/src-tauri/client-cli/src/commands/list.rs +++ b/src-tauri/client-cli/src/commands/list.rs @@ -16,7 +16,11 @@ const MIN_TUNNEL_NAME_COL_WIDTH: usize = 4; pub(crate) async fn handle(state: &State) -> Result { let instances = Instance::all(&state.pool).await?; let locations = Location::all(&state.pool, false).await?; - let tunnels = Tunnel::all(&state.pool).await?; + let tunnels = if Instance::tunnels_disabled(&state.pool).await? { + Vec::new() + } else { + Tunnel::all(&state.pool).await? + }; Ok(ListResult { instances, locations, @@ -194,6 +198,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } diff --git a/src-tauri/client-cli/src/commands/tunnel.rs b/src-tauri/client-cli/src/commands/tunnel.rs index 9fa069c7b..dc09f9444 100644 --- a/src-tauri/client-cli/src/commands/tunnel.rs +++ b/src-tauri/client-cli/src/commands/tunnel.rs @@ -1,4 +1,4 @@ -use defguard_core::database::models::{tunnel::Tunnel, Id}; +use defguard_core::database::models::{instance::Instance, tunnel::Tunnel, Id}; use serde_json::{json, Value}; use crate::{ @@ -11,11 +11,13 @@ const MIN_ADDR_COL_WIDTH: usize = 7; const MIN_ENDPOINT_COL_WIDTH: usize = 8; pub async fn handle_list(state: &State) -> Result { + Instance::ensure_tunnels_enabled(&state.pool).await?; let tunnels = Tunnel::all(&state.pool).await?; Ok(TunnelListResult { tunnels }) } pub async fn handle_show(state: &State, name: &str) -> Result { + Instance::ensure_tunnels_enabled(&state.pool).await?; let tunnels = Tunnel::find_by_name(&state.pool, name).await?; let tunnel = match tunnels.len() { 0 => { diff --git a/src-tauri/client-cli/src/resolve.rs b/src-tauri/client-cli/src/resolve.rs index 49a4a0775..f05366b0b 100644 --- a/src-tauri/client-cli/src/resolve.rs +++ b/src-tauri/client-cli/src/resolve.rs @@ -168,6 +168,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } diff --git a/src-tauri/client-cli/src/tests_daemon.rs b/src-tauri/client-cli/src/tests_daemon.rs index 8e404515a..7f298616d 100644 --- a/src-tauri/client-cli/src/tests_daemon.rs +++ b/src-tauri/client-cli/src/tests_daemon.rs @@ -170,6 +170,7 @@ async fn test_active_state_lists_interfaces(pool: DbPool) { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } .save(&pool) diff --git a/src-tauri/core/src/database/models/connection.rs b/src-tauri/core/src/database/models/connection.rs index 4ea2d0076..9cb87d079 100644 --- a/src-tauri/core/src/database/models/connection.rs +++ b/src-tauri/core/src/database/models/connection.rs @@ -182,6 +182,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } .save(pool) diff --git a/src-tauri/core/src/database/models/instance.rs b/src-tauri/core/src/database/models/instance.rs index 1b3a91e2a..38c03d587 100644 --- a/src-tauri/core/src/database/models/instance.rs +++ b/src-tauri/core/src/database/models/instance.rs @@ -1,7 +1,7 @@ use std::fmt; use serde::{Deserialize, Serialize}; -use sqlx::{prelude::Type, query, query_as, SqliteExecutor}; +use sqlx::{prelude::Type, query, query_as, query_scalar, SqliteExecutor}; use super::{Id, NoId}; use crate::proto; @@ -17,6 +17,7 @@ pub struct Instance { pub token: Option, pub client_traffic_policy: ClientTrafficPolicy, pub enterprise_enabled: bool, + pub disable_tunnels: bool, pub openid_display_name: Option, } @@ -39,6 +40,7 @@ impl From for Instance { token: None, client_traffic_policy, enterprise_enabled: instance_info.enterprise_enabled, + disable_tunnels: instance_info.disable_tunnels.unwrap_or(false), openid_display_name: instance_info.openid_display_name, } } @@ -51,9 +53,9 @@ impl Instance { { query!( "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, \ - client_traffic_policy = $6, enterprise_enabled = $7, token = $8, \ - openid_display_name = $9 \ - WHERE id = $10;", + client_traffic_policy = $6, enterprise_enabled = $7, disable_tunnels = $8, token = $9, \ + openid_display_name = $10 \ + WHERE id = $11;", self.name, self.uuid, self.url, @@ -61,6 +63,7 @@ impl Instance { self.username, self.client_traffic_policy, self.enterprise_enabled, + self.disable_tunnels, self.token, self.openid_display_name, self.id @@ -77,7 +80,7 @@ impl Instance { let instances = query_as!( Self, "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ - client_traffic_policy, enterprise_enabled, openid_display_name \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ FROM instance ORDER BY name ASC;" ) .fetch_all(executor) @@ -92,7 +95,7 @@ impl Instance { let instance = query_as!( Self, "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ - client_traffic_policy, enterprise_enabled, openid_display_name \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ FROM instance WHERE id = $1;", id ) @@ -108,7 +111,7 @@ impl Instance { let instance = query_as!( Self, "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ - client_traffic_policy, enterprise_enabled, openid_display_name \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ FROM instance WHERE name = $1;", name ) @@ -143,7 +146,7 @@ impl Instance { let instances = query_as!( Self, "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, \ - client_traffic_policy, enterprise_enabled, openid_display_name \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ FROM instance \ WHERE token IS NOT NULL ORDER BY name ASC;" ) @@ -151,6 +154,30 @@ impl Instance { .await?; Ok(instances) } + + /// True if ANY enrolled instance has `disable_tunnels = true`. + /// False when there are 0 instances (no policy delivery path, so tunnels are available). + pub async fn tunnels_disabled<'e, E>(executor: E) -> Result + where + E: SqliteExecutor<'e>, + { + let flags = query_scalar!(r#"SELECT disable_tunnels as "disable_tunnels!" FROM instance"#) + .fetch_all(executor) + .await?; + Ok(!flags.is_empty() && flags.iter().any(|v| *v)) + } + + /// Hard-refuse guard for tunnel operations: returns `Error::TunnelsDisabled` + /// when any enrolled instance disables tunnels, `Ok(())` otherwise. + pub async fn ensure_tunnels_enabled<'e, E>(executor: E) -> Result<(), crate::error::Error> + where + E: SqliteExecutor<'e>, + { + if Self::tunnels_disabled(executor).await? { + return Err(crate::error::Error::TunnelsDisabled); + } + Ok(()) + } } // This compares proto::InstanceInfo, not to be confused with regular InstanceInfo defined below @@ -164,6 +191,7 @@ impl PartialEq for Instance { && self.username == other.username && self.client_traffic_policy == other_policy && self.enterprise_enabled == other.enterprise_enabled + && self.disable_tunnels == other.disable_tunnels.unwrap_or(false) && self.openid_display_name == other.openid_display_name } } @@ -177,8 +205,8 @@ impl Instance { let proxy_url = self.proxy_url.clone(); let result = query!( "INSERT INTO instance (name, uuid, url, proxy_url, username, token, \ - client_traffic_policy , enterprise_enabled) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id;", + client_traffic_policy , enterprise_enabled, disable_tunnels) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id;", self.name, self.uuid, url, @@ -186,7 +214,8 @@ impl Instance { self.username, self.token, self.client_traffic_policy, - self.enterprise_enabled + self.enterprise_enabled, + self.disable_tunnels ) .fetch_one(executor) .await?; @@ -200,6 +229,7 @@ impl Instance { token: self.token, client_traffic_policy: self.client_traffic_policy, enterprise_enabled: self.enterprise_enabled, + disable_tunnels: self.disable_tunnels, openid_display_name: self.openid_display_name, }) } @@ -216,6 +246,7 @@ pub struct InstanceInfo { pub pubkey: String, pub client_traffic_policy: ClientTrafficPolicy, pub enterprise_enabled: bool, + pub disable_tunnels: bool, pub openid_display_name: Option, } @@ -295,6 +326,7 @@ mod tests { token: Some("token".into()), client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } @@ -416,5 +448,97 @@ mod tests { assert!(instance.enterprise_enabled); assert_eq!(instance.openid_display_name, Some("OIDC".to_string())); assert_eq!(instance.client_traffic_policy, ClientTrafficPolicy::None); + assert!(!instance.disable_tunnels); + } + + fn new_instance_with_tunnels_disabled(disable: bool) -> Instance { + let mut inst = new_instance(); + inst.disable_tunnels = disable; + inst + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_zero_instances(pool: SqlitePool) { + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_one_on(pool: SqlitePool) { + new_instance_with_tunnels_disabled(true) + .save(&pool) + .await + .unwrap(); + assert!(Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_one_off(pool: SqlitePool) { + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_mixed(pool: SqlitePool) { + new_instance_with_tunnels_disabled(true) + .save(&pool) + .await + .unwrap(); + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_all_off(pool: SqlitePool) { + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[test] + fn test_instance_from_proto_disable_tunnels_none() { + let info = base_info(); + let instance: Instance = info.into(); + assert!(!instance.disable_tunnels); + } + + #[test] + fn test_instance_from_proto_disable_tunnels_true() { + let mut info = base_info(); + info.disable_tunnels = Some(true); + let instance: Instance = info.into(); + assert!(instance.disable_tunnels); + } + + #[test] + fn test_instance_partial_eq_detect_disable_tunnels_flip() { + let mut info = base_info(); + info.disable_tunnels = Some(true); + let instance = Instance:: { + id: 1, + name: info.name.clone(), + uuid: info.id.clone(), + url: info.url.clone(), + proxy_url: info.proxy_url.clone(), + username: info.username.clone(), + token: Some("tok".into()), + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: info.enterprise_enabled, + disable_tunnels: false, + openid_display_name: info.openid_display_name.clone(), + }; + // Model has false, proto has true → not equal. + assert_ne!(instance, info); } } diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs index b46fefd62..297ecf179 100644 --- a/src-tauri/core/src/database/models/location.rs +++ b/src-tauri/core/src/database/models/location.rs @@ -593,6 +593,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } diff --git a/src-tauri/core/src/database/models/location_stats.rs b/src-tauri/core/src/database/models/location_stats.rs index aad5f3255..cd9780b9d 100644 --- a/src-tauri/core/src/database/models/location_stats.rs +++ b/src-tauri/core/src/database/models/location_stats.rs @@ -242,6 +242,7 @@ mod tests { token: None, client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } .save(pool) diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs index 60b68484b..79fd8f1bf 100644 --- a/src-tauri/core/src/error.rs +++ b/src-tauri/core/src/error.rs @@ -42,6 +42,8 @@ pub enum Error { CommandError(String), #[error("Core is not enterprise")] CoreNotEnterprise, + #[error("Tunnels are disabled by the server administrator")] + TunnelsDisabled, #[error("Instance has no config polling token")] NoToken, #[error("Failed to lock app state member.")] diff --git a/src-tauri/core/src/events.rs b/src-tauri/core/src/events.rs index 2d3e1a69e..b09159472 100644 --- a/src-tauri/core/src/events.rs +++ b/src-tauri/core/src/events.rs @@ -21,6 +21,8 @@ pub enum EventKey { MfaOpenIdError, MfaMobileComplete, MfaMobileError, + TunnelsDisabled, + TunnelsEnabled, } impl From for &'static str { @@ -45,6 +47,8 @@ impl From for &'static str { EventKey::MfaOpenIdError => "mfa-openid-error", EventKey::MfaMobileComplete => "mfa-mobile-complete", EventKey::MfaMobileError => "mfa-mobile-error", + EventKey::TunnelsDisabled => "tunnel-disabled-by-policy", + EventKey::TunnelsEnabled => "tunnel-enabled-by-policy", } } } diff --git a/src-tauri/enterprise/config-sync/src/commands.rs b/src-tauri/enterprise/config-sync/src/commands.rs index d2992353b..12d70ab2f 100644 --- a/src-tauri/enterprise/config-sync/src/commands.rs +++ b/src-tauri/enterprise/config-sync/src/commands.rs @@ -68,6 +68,7 @@ pub async fn do_update_instance( } instance.client_traffic_policy = instance_info.client_traffic_policy.into(); instance.openid_display_name = instance_info.openid_display_name; + instance.disable_tunnels = instance_info.disable_tunnels.unwrap_or(false); instance.uuid = instance_info.id; if response.token.is_some() { instance.token = response.token; diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index 3a1bbe649..909a308e0 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -194,6 +194,19 @@ pub async fn poll_instance( ); if has_active_connections { + // add dedicated override to disable tunnels without waiting for a disconnect + if let Some(ref info) = device_config.instance { + let new_tunnels_disabled = info.disable_tunnels.unwrap_or(false); + if new_tunnels_disabled && !instance.disable_tunnels { + debug!( + "Tunnels were disabled for instance {}({}) while a connection is active, \ + persisting the flag immediately.", + instance.name, instance.id + ); + instance.disable_tunnels = true; + instance.save(transaction.as_mut()).await?; + } + } return Ok(PollInstanceResult::ChangedWhileActive { version_mismatch }); } @@ -520,6 +533,7 @@ mod tests { token: token.map(str::to_string), client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: false, + disable_tunnels: false, openid_display_name: None, } } @@ -600,6 +614,7 @@ mod tests { token: token.map(str::to_string), client_traffic_policy: ClientTrafficPolicy::None, enterprise_enabled: true, + disable_tunnels: false, openid_display_name: None, } .save(pool) diff --git a/src-tauri/migrations/20260724000000_disable_tunnels.sql b/src-tauri/migrations/20260724000000_disable_tunnels.sql new file mode 100644 index 000000000..5094975e8 --- /dev/null +++ b/src-tauri/migrations/20260724000000_disable_tunnels.sql @@ -0,0 +1 @@ +ALTER TABLE instance ADD COLUMN disable_tunnels BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src-tauri/proto b/src-tauri/proto index 053e83dc4..cbb798774 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 053e83dc45f0effe19e4f7faad6ccba2c458c241 +Subproject commit cbb798774a48e77940de33b8d6df7dae519541a4 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e95355f91..efd634f47 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -55,7 +55,7 @@ use crate::{ DB_POOL, }, error::Error, - events::EventKey, + events::{EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, into_location, log_watcher::{ global_log_watcher::{spawn_global_log_watcher_task, stop_global_log_watcher_task}, @@ -166,6 +166,7 @@ pub async fn connect( return Err(Error::NotFound.into()); } } else if let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!( "Identified tunnel with ID {location_id} as \"{}\", handling connection...", tunnel.name @@ -269,6 +270,39 @@ pub async fn disconnect( } } +pub async fn disconnect_all_tunnels(handle: &AppHandle) -> Result<(), Error> { + let state = handle.state::(); + let tunnel_ids = get_connection_id_by_type(ConnectionType::Tunnel).await; + if tunnel_ids.is_empty() { + debug!("No active tunnels to disconnect, emitting TunnelsDisabled event anyway"); + TunnelsDisabledPayload::emit(handle, Vec::new()); + return Ok(()); + } + + let mut names = Vec::new(); + for tunnel_id in &tunnel_ids { + let name = get_tunnel_or_location_name(*tunnel_id, ConnectionType::Tunnel).await; + debug!("Tunnels are disabled, disconnecting tunnel {name}(ID: {tunnel_id})"); + if let Some(connection) = state + .remove_connection(*tunnel_id, ConnectionType::Tunnel) + .await + { + disconnect_interface(&connection).await?; + stop_log_watcher_task(handle, &connection.interface_name)?; + info!("Tunnel {name}(ID: {tunnel_id}) disconnected (disabled by server administrator)"); + names.push(name); + } + } + + TunnelsDisabledPayload::emit(handle, names); + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(tauri_err_to_app_err)?; + reload_tray_menu(handle).await; + configure_tray_icon(handle).await?; + Ok(()) +} + #[tauri::command(async)] pub async fn disconnect_locations(location_ids: Vec, handle: AppHandle) -> Result<(), Error> { debug!( @@ -433,6 +467,10 @@ pub async fn save_device_config( info!("New instance {instance} created."); trace!("Created following instance: {instance:#?}"); + if Instance::tunnels_disabled(&*DB_POOL).await? { + disconnect_all_tunnels(&handle).await?; + } + let locations = push_service_locations(&instance, keys).await?; handle @@ -542,6 +580,7 @@ pub async fn all_instances() -> Result>, Error> { pubkey: keys.pubkey, client_traffic_policy: instance.client_traffic_policy, enterprise_enabled: instance.enterprise_enabled, + disable_tunnels: instance.disable_tunnels, openid_display_name: instance.openid_display_name, }); } @@ -921,10 +960,15 @@ pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), E } } + let was_disabled = Instance::tunnels_disabled(&*DB_POOL).await?; instance.delete(&mut *transaction).await?; transaction.commit().await?; + if was_disabled && !Instance::tunnels_disabled(&*DB_POOL).await? { + TunnelsEnabledPayload::emit(&handle); + } + reload_tray_menu(&handle).await; configure_tray_icon(&handle).await?; @@ -985,10 +1029,15 @@ pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), E ); } } + let was_disabled = Instance::tunnels_disabled(&*DB_POOL).await?; instance.delete(&mut *transaction).await?; transaction.commit().await?; + if was_disabled && !Instance::tunnels_disabled(&*DB_POOL).await? { + TunnelsEnabledPayload::emit(&handle); + } + client .delete_service_locations(DeleteServiceLocationsRequest { instance_id: instance.uuid.clone(), @@ -1026,6 +1075,7 @@ pub fn parse_tunnel_config(filename: &str, config: &str) -> Result, handle: AppHandle) -> Result<(), Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Received tunnel configuration to update: {tunnel}"); tunnel.save(&*DB_POOL).await?; info!("The tunnel {tunnel} configuration has been updated."); @@ -1037,6 +1087,7 @@ pub async fn update_tunnel(mut tunnel: Tunnel, handle: AppHandle) -> Result< #[tauri::command(async)] pub async fn save_tunnel(tunnel: Tunnel, handle: AppHandle) -> Result<(), Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Received tunnel configuration to save: {tunnel}"); let tunnel = tunnel.save(&*DB_POOL).await?; info!("The tunnel {tunnel} configuration has been saved."); @@ -1059,6 +1110,11 @@ pub struct TunnelInfo { #[tauri::command(async)] pub async fn all_tunnels() -> Result>, Error> { + // Soft-hide: report no tunnels (rather than erroring) so callers render an empty + // list. Mutating/connecting commands hard-refuse via `ensure_tunnels_enabled`. + if Instance::tunnels_disabled(&*DB_POOL).await? { + return Ok(Vec::new()); + } trace!("Getting information about all tunnels"); let tunnels = Tunnel::all(&*DB_POOL).await?; @@ -1087,6 +1143,7 @@ pub async fn all_tunnels() -> Result>, Error> { #[tauri::command(async)] pub async fn tunnel_details(tunnel_id: Id) -> Result, Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Retrieving details about tunnel with ID {tunnel_id}."); if let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, tunnel_id).await? { diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index be4a2874f..2ff829ef6 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -68,6 +68,42 @@ pub struct AddInstancePayload<'a> { pub url: &'a str, } +#[derive(Clone, Serialize)] +pub struct TunnelsDisabledPayload { + pub names: Vec, +} + +impl TunnelsDisabledPayload { + pub fn emit(app_handle: &AppHandle, names: Vec) { + let payload = Self { names }; + for name in &payload.names { + if let Err(err) = app_handle + .notification() + .builder() + .title(format!("Tunnel {name} disconnected")) + .body("WireGuard tunnels have been disabled by the administrator.") + .show() + { + warn!("Tunnels disabled notification not shown. Reason: {err}"); + } + } + if let Err(err) = app_handle.emit(EventKey::TunnelsDisabled.into(), payload) { + error!("Event TunnelsDisabled was not emitted. Reason: {err}"); + } + } +} + +#[derive(Clone, Serialize)] +pub struct TunnelsEnabledPayload; + +impl TunnelsEnabledPayload { + pub fn emit(app_handle: &AppHandle) { + if let Err(err) = app_handle.emit(EventKey::TunnelsEnabled.into(), Self) { + error!("Event TunnelsEnabled was not emitted. Reason: {err}"); + } + } +} + /// Handle deep-link URLs. pub fn handle_deep_link(app_handle: &AppHandle, urls: &[Url]) { debug!("Deep link received."); diff --git a/src-tauri/src/periodic/config.rs b/src-tauri/src/periodic/config.rs index 30672e6c2..ad7b6ad5f 100644 --- a/src-tauri/src/periodic/config.rs +++ b/src-tauri/src/periodic/config.rs @@ -25,6 +25,8 @@ use sqlx::{Sqlite, Transaction}; use tauri::{AppHandle, Emitter}; use tokio::time::sleep; +use crate::{commands::disconnect_all_tunnels, events::TunnelsEnabledPayload}; + const INTERVAL_SECONDS: Duration = Duration::from_secs(30); /// Tracks instance IDs for which we already sent a version-mismatch notification, @@ -39,6 +41,7 @@ pub async fn poll_config(handle: AppHandle) { debug!("Starting the configuration polling loop."); // Polling starts sooner than app's frontend may load in dev builds, causing events (toasts) // to be lost; you may want to wait here before starting if you want to debug it. + let mut last_tunnels_disabled = false; loop { let active_instance_ids = match active_instance_ids().await { Ok(ids) => ids, @@ -103,6 +106,24 @@ pub async fn poll_config(handle: AppHandle) { if let Err(err) = handle.emit(EventKey::InstanceUpdate.into(), ()) { error!("Failed to emit instance update event to the frontend: {err}"); } + + let currently_disabled = Instance::tunnels_disabled(&*DB_POOL).await.unwrap_or(false); + + match (last_tunnels_disabled, currently_disabled) { + (false, true) => { + info!("Tunnels disabled by server administrator, disconnecting any active tunnels"); + if let Err(err) = disconnect_all_tunnels(&handle).await { + error!("Failed to disconnect tunnels after tunnels were disabled: {err}"); + } + } + (true, false) => { + info!("Tunnels re-enabled by server administrator"); + TunnelsEnabledPayload::emit(&handle); + } + _ => {} + } + last_tunnels_disabled = currently_disabled; + if config_retrieved > 0 { info!( "Automatically retrieved the newest instance configuration from core for {config_retrieved} instances, sleeping for {}s",