diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index a0cdcc57..f49b0076 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -35,7 +35,7 @@ jobs: - name: Install system dependencies run: | apt-get update - apt-get install -y build-essential ca-certificates desktop-file-utils curl git \ + apt-get install -y build-essential ca-certificates dbus desktop-file-utils curl git \ iproute2 iputils-ping libayatana-appindicator3-dev libgtk-3-dev libprotobuf-dev \ librsvg2-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf procps \ protobuf-compiler webkit2gtk-driver xvfb wireguard-tools xclip xdg-utils @@ -106,7 +106,7 @@ jobs: working-directory: e2e - name: Run e2e tests - run: xvfb-run -a pnpm test + run: dbus-run-session -- xvfb-run -a pnpm test working-directory: e2e - name: Collect logs diff --git a/e2e/README.md b/e2e/README.md index 7f64b652..a931a1ad 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,7 +12,7 @@ Defguard services to be running separately. - An administrator account whose login does not require MFA. The password is supplied through `e2e/.env`. - A dedicated deployment with exactly one test network named `e2e`. The connection checks use the first visible connect button. - Root `defguard-service` running with `/var/run/defguard.socket` available. -- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, and `xvfb-run` available. +- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, `xvfb-run`, and `dbus-run-session` available. - `tauri-driver` available at `~/.cargo/bin/tauri-driver`, or at the path set by `TAURI_DRIVER`. ## Configure the test environment @@ -58,6 +58,6 @@ To run one spec directly: ```bash cd e2e NATIVE_DRIVER="$(command -v WebKitWebDriver)" \ - xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \ + dbus-run-session -- xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \ --spec=./tests/enrollment.spec.ts ``` diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts new file mode 100644 index 00000000..fd0f477c --- /dev/null +++ b/e2e/helpers/launchConfig.ts @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type LaunchConfig = { + files?: Record; + args?: string[]; +}; + +type ProvisioningFixture = { enrollmentUrl: string; enrollmentToken: string }; +type DeepLinkFixture = { url: string; token: string }; + +export const provisioningFixture: ProvisioningFixture = { + enrollmentUrl: "https://enroll.example", + enrollmentToken: "e2e-provisioning-token", +}; + +export const coldStartFixture: { + provisioning: ProvisioningFixture; + deepLink: DeepLinkFixture; +} = { + provisioning: { + enrollmentUrl: "https://provisioning.example", + enrollmentToken: "e2e-provisioning-token", + }, + deepLink: { + url: "https://cold-start.example", + token: "e2e-cold-start-token", + }, +}; + +export const provisioningFile = ({ + enrollmentUrl, + enrollmentToken, +}: ProvisioningFixture): string => + JSON.stringify({ + enrollment_url: enrollmentUrl, + enrollment_token: enrollmentToken, + }); + +export const deepLinkArg = ({ url, token }: DeepLinkFixture): string => + `defguard://addinstance?token=${encodeURIComponent(token)}&url=${encodeURIComponent(url)}`; + +export const launchConfigs: Record = { + "deep_link_cold_start.spec.ts": { + files: { + "provisioning.json": provisioningFile(coldStartFixture.provisioning), + }, + args: [deepLinkArg(coldStartFixture.deepLink)], + }, + "provisioning.spec.ts": { + files: { "provisioning.json": provisioningFile(provisioningFixture) }, + }, +}; + +export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { + const configured = specs + .map((spec) => path.basename(spec)) + .filter((specName) => Object.hasOwn(launchConfigs, specName)); + if (configured.length > 0 && specs.length > 1) { + throw new Error( + `Specs with a launch config must run in their own session: ${configured.join(", ")}`, + ); + } + return configured.length > 0 ? launchConfigs[configured[0]] : undefined; +}; + +export const assertLaunchConfigSpecsExist = (testsDir: string): void => { + for (const specName of Object.keys(launchConfigs)) { + if (!fs.existsSync(path.join(testsDir, specName))) { + throw new Error(`Launch config refers to missing spec tests/${specName}`); + } + } +}; + +export const writeLaunchFiles = ( + dataDir: string, + files: Record, +): void => { + const appDataDir = path.join(dataDir, "share", "net.defguard"); + fs.mkdirSync(appDataDir, { recursive: true }); + for (const [fileName, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(appDataDir, fileName), contents); + } +}; diff --git a/e2e/tests/deep_link_cold_start.spec.ts b/e2e/tests/deep_link_cold_start.spec.ts new file mode 100644 index 00000000..f0bc8e8e --- /dev/null +++ b/e2e/tests/deep_link_cold_start.spec.ts @@ -0,0 +1,22 @@ +import { $, browser, expect } from "@wdio/globals"; +import { coldStartFixture } from "../helpers/launchConfig.js"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const { token, url } = coldStartFixture.deepLink; + +describe("cold-start deep link", () => { + it("prioritizes the deep link over provisioning config", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { timeout: 30_000, timeoutMsg: "Cold-start deep link was not handled" }, + ); + + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(token); + expect(currentUrl.searchParams.get("url")).toBe(url); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); +}); diff --git a/e2e/tests/deep_link_running_app.spec.ts b/e2e/tests/deep_link_running_app.spec.ts new file mode 100644 index 00000000..f3a56316 --- /dev/null +++ b/e2e/tests/deep_link_running_app.spec.ts @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import { $, browser, expect } from "@wdio/globals"; +import { deepLinkArg } from "../helpers/launchConfig.js"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const token = "e2e-running-app-token"; +const url = "https://running.example"; + +describe("running-app deep link", () => { + it("forwards the link to the running client", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/add", + { timeout: 30_000, timeoutMsg: "Full view did not open" }, + ); + await expect($("#add-page-view")).toBeDisplayed(); + + const clientBinary = process.env.CLIENT_BINARY; + if (!clientBinary) { + throw new Error("CLIENT_BINARY was not set by the e2e session"); + } + if (!process.env.DBUS_SESSION_BUS_ADDRESS) { + throw new Error("The e2e process has no D-Bus session"); + } + + const secondInstance = spawnSync( + clientBinary, + [deepLinkArg({ token, url })], + { env: { ...process.env }, stdio: "ignore", timeout: 15_000 }, + ); + if (secondInstance.error) throw secondInstance.error; + expect(secondInstance.status).toBe(0); + expect(secondInstance.signal).toBeNull(); + + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { + timeout: 15_000, + timeoutMsg: "Running client did not handle the deep link", + }, + ); + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(token); + expect(currentUrl.searchParams.get("url")).toBe(url); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); +}); diff --git a/e2e/tests/provisioning.spec.ts b/e2e/tests/provisioning.spec.ts new file mode 100644 index 00000000..fd84b29c --- /dev/null +++ b/e2e/tests/provisioning.spec.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { $, browser, expect } from "@wdio/globals"; +import { invoke } from "../helpers/client.js"; +import { provisioningFixture } from "../helpers/launchConfig.js"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const { enrollmentToken, enrollmentUrl } = provisioningFixture; + +describe("provisioning", () => { + it("opens the prefilled instance form from launch config", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { timeout: 10_000, timeoutMsg: "Provisioning form did not open" }, + ); + + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(enrollmentToken); + expect(currentUrl.searchParams.get("url")).toBe(enrollmentUrl); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); + + it("does not restore provisioning after the saved instance is deleted", async () => { + await switchToWindowLabel("full-view"); + const saved = await invoke<{ instance: { id: number } }>( + "save_device_config", + { + privateKey: "e2e-placeholder-private-key", + response: { + device: { + id: 1, + name: "e2e-device", + pubkey: "e2e-placeholder-public-key", + user_id: 1, + created_at: 0, + }, + configs: [], + instance: { + id: "00000000-0000-4000-8000-000000000001", + name: "e2e-instance", + url: "https://core.example", + proxy_url: "https://edge.example", + username: "e2e", + }, + }, + }, + ); + const dataDir = process.env.E2E_DATA_DIR; + if (!dataDir) throw new Error("E2E_DATA_DIR is required"); + expect( + fs.existsSync( + path.join(dataDir, "share", "net.defguard", "provisioning.json"), + ), + ).toBe(true); + + await browser.url("tauri://localhost/full/"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/overview", + { timeout: 10_000, timeoutMsg: "Expected the saved-instance overview" }, + ); + + await invoke("delete_instance", { instanceId: saved.instance.id }); + await browser.url("tauri://localhost/full/"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/add", + { timeout: 10_000, timeoutMsg: "Expected the empty-instance add page" }, + ); + await expect($("#add-instance-view")).not.toBeDisplayed(); + }); +}); diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index bf8c3cb5..1c5e6375 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -2,6 +2,11 @@ import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + assertLaunchConfigSpecsExist, + getLaunchConfig, + writeLaunchFiles, +} from "./helpers/launchConfig.js"; const here = import.meta.dirname; @@ -85,25 +90,44 @@ export const config: WebdriverIO.Config = { "`pnpm tauri build` or set CLIENT_BINARY.", ); } + assertLaunchConfigSpecsExist(path.join(here, "tests")); }, - beforeSession: () => { + beforeSession: (_config, caps, specs) => { killLeftoverClients(); cleanupWireguardInterfaces(); dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-")); + const launchConfig = getLaunchConfig(specs); + const sessionEnv = { + CLIENT_BINARY: clientBinary, + DEFGUARD_CLIENT_WELCOME_SKIP: "1", + E2E_DATA_DIR: dataDir, + XDG_DATA_HOME: path.join(dataDir, "share"), + XDG_CONFIG_HOME: path.join(dataDir, "config"), + XDG_CACHE_HOME: path.join(dataDir, "cache"), + }; + Object.assign(process.env, sessionEnv); + + if (launchConfig?.files) { + writeLaunchFiles(dataDir, launchConfig.files); + } + + if (launchConfig?.args) { + const tauriOptions = caps["tauri:options"] as + | { args?: string[] } + | undefined; + if (!tauriOptions) { + throw new Error( + "Missing tauri:options capability for launch arguments", + ); + } + tauriOptions.args = launchConfig.args; + } + tauriDriver = spawn( tauriDriverBinary, nativeDriver ? ["--native-driver", nativeDriver] : [], - { - stdio: ["ignore", "inherit", "inherit"], - env: { - ...process.env, - DEFGUARD_CLIENT_WELCOME_SKIP: "1", - XDG_DATA_HOME: path.join(dataDir, "share"), - XDG_CONFIG_HOME: path.join(dataDir, "config"), - XDG_CACHE_HOME: path.join(dataDir, "cache"), - }, - }, + { stdio: ["ignore", "inherit", "inherit"] }, ); tauriDriver.on("error", (error) => { console.error("tauri-driver failed to start:", error); diff --git a/justfile b/justfile index 794212b5..06c1fc63 100644 --- a/justfile +++ b/justfile @@ -34,9 +34,10 @@ e2e-test: test "$(uname -s)" = "Linux" || { echo "Client E2E tests require Linux." >&2; exit 1; } test -f e2e/.env || { echo "Missing e2e/.env; copy e2e/.env.example and fill it locally." >&2; exit 1; } test -S /var/run/defguard.socket || { echo "defguard-service is not running at /var/run/defguard.socket." >&2; exit 1; } - test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; install it with: cargo install tauri-driver --locked" >&2; exit 1; } + test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; set TAURI_DRIVER to its location." >&2; exit 1; } command -v WebKitWebDriver >/dev/null || { echo "WebKitWebDriver is missing; enter nix develop." >&2; exit 1; } command -v xvfb-run >/dev/null || { echo "xvfb-run is missing; enter nix develop." >&2; exit 1; } - cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" xvfb-run -a pnpm test + command -v dbus-run-session >/dev/null || { echo "dbus-run-session is missing; enter nix develop." >&2; exit 1; } + cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test e2e: e2e-build e2e-provision e2e-test diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts new file mode 100644 index 00000000..e15f4b46 --- /dev/null +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveFullIndexRedirect } from './resolveFullIndexRedirect'; + +describe('resolveFullIndexRedirect', () => { + it('adds an empty client when no provisioning config exists', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 0, + provisioningConfig: null, + }), + ).toEqual({ to: '/full/add' }); + }); + + it('opens the prefilled add-instance form for an empty client with config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 0, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ + to: '/full/add/instance', + search: { + url: 'https://enroll.example', + token: 'provisioning-token', + }, + }); + }); + + it('opens the prefilled form even if tunnels exist', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 1, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ + to: '/full/add/instance', + search: { + url: 'https://enroll.example', + token: 'provisioning-token', + }, + }); + }); + + it('shows overview when an instance exists despite config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 1, + tunnelCount: 0, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ to: '/full/overview' }); + }); + + it('shows overview for an empty client with tunnels and no config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 1, + provisioningConfig: null, + }), + ).toEqual({ to: '/full/overview' }); + }); +}); diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts new file mode 100644 index 00000000..dc46b126 --- /dev/null +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts @@ -0,0 +1,32 @@ +import type { ProvisioningConfig } from '../../../shared/rust-api/types'; + +type ResolveFullIndexRedirectArgs = { + instanceCount: number; + tunnelCount: number; + provisioningConfig: ProvisioningConfig | null; +}; + +type FullIndexRedirect = + | { to: '/full/add/instance'; search: { url: string; token: string } } + | { to: '/full/add' } + | { to: '/full/overview' }; + +export const resolveFullIndexRedirect = ({ + instanceCount, + tunnelCount, + provisioningConfig, +}: ResolveFullIndexRedirectArgs): FullIndexRedirect => { + if (instanceCount === 0 && provisioningConfig) { + return { + to: '/full/add/instance', + search: { + url: provisioningConfig.enrollment_url, + token: provisioningConfig.enrollment_token, + }, + }; + } + if (instanceCount === 0 && tunnelCount === 0) { + return { to: '/full/add' }; + } + return { to: '/full/overview' }; +}; diff --git a/new-ui/src/routes/full/index.tsx b/new-ui/src/routes/full/index.tsx index 1ad5e6e8..7dee0530 100644 --- a/new-ui/src/routes/full/index.tsx +++ b/new-ui/src/routes/full/index.tsx @@ -1,21 +1,44 @@ import { createFileRoute, Navigate, redirect } from '@tanstack/react-router'; +import { error as logError } from '@tauri-apps/plugin-log'; +import { api } from '../../shared/rust-api/api'; import { getInstancesQueryOptions, + getProvisioningConfigQueryOptions, getTunnelsQueryOptions, } from '../../shared/rust-api/query'; +import { resolveFullIndexRedirect } from './-utils/resolveFullIndexRedirect'; export const Route = createFileRoute('/full/')({ beforeLoad: async ({ context }) => { - const [instances, tunnels] = await Promise.all([ + const pendingAddInstance = await api.takePendingAddInstance().catch(() => { + void logError('Failed to fetch pending add-instance deep link.'); + return null; + }); + if (pendingAddInstance) { + throw redirect({ + to: '/full/add/instance', + search: { url: pendingAddInstance.url, token: pendingAddInstance.token }, + }); + } + + const [instances, tunnels, provisioningConfig] = await Promise.all([ context.queryClient.fetchQuery(getInstancesQueryOptions), context.queryClient.fetchQuery(getTunnelsQueryOptions), + context.queryClient + .fetchQuery({ ...getProvisioningConfigQueryOptions, staleTime: 0 }) + .catch(() => { + void logError('Failed to fetch provisioning config.'); + return null; + }), ]); - if (instances.length === 0 && tunnels.length === 0) { - throw redirect({ to: '/full/add' }); - } else { - throw redirect({ to: '/full/overview' }); - } + throw redirect( + resolveFullIndexRedirect({ + instanceCount: instances.length, + tunnelCount: tunnels.length, + provisioningConfig, + }), + ); }, component: RouteComponent, }); diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx index c1e7407f..64d8dc12 100644 --- a/new-ui/src/shared/providers/TauriEventProvider.tsx +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -18,7 +18,6 @@ import { useAppData } from '../providers/AppDataContext'; import { api } from '../rust-api/api'; import { getInstancesQueryOptions } from '../rust-api/query'; import { - type AddInstanceEventPayload, type ConfigureFactorsPayload, ConnectionType, type DeadConnectionDroppedPayload, @@ -30,12 +29,41 @@ import { import { useAppStore } from '../store/useAppStore'; import { resolveMfaStepPlan, shouldStartMfa } from '../utils/mfa'; +const urlOrigin = (url: string): string => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + export const TauriEventProvider = ({ children }: PropsWithChildren) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { setViewSelection } = useAppData(); useEffect(() => { + const openPendingAddInstance = async () => { + if (getCurrentWindow().label !== WindowId.FullView) return; + + try { + const pendingAddInstance = await api.takePendingAddInstance(); + if (!pendingAddInstance) return; + void debug( + `Opening pending add-instance deep link (${urlOrigin(pendingAddInstance.url)})`, + ); + void navigate({ + to: '/full/add/instance', + search: { + token: pendingAddInstance.token, + url: pendingAddInstance.url, + }, + }); + } catch (err) { + void logError(`Failed to retrieve pending add-instance deep link: ${err}`); + } + }; + const openMfaModal = async (location: LocationInfo) => { const appConfig = await api.getAppConfig(); // A failed refresh keeps the cached instances. With none, the plan uses @@ -65,20 +93,7 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { }; const unlisteners = Promise.all([ - listen(TauriEvent.AddInstance, (event) => { - void debug(`UI Received event AddInstance (${event.payload.url})`); - const windowLabel = getCurrentWindow().label; - if (windowLabel === WindowId.FullView) { - const { token, url } = event.payload; - navigate({ - to: '/full/add/instance', - search: { - token, - url, - }, - }); - } - }), + listen(TauriEvent.AddInstance, () => void openPendingAddInstance()), // Backend requests the MFA flow (e.g. connecting to an MFA location from the // tray or system settings). The location is emitted as the payload directly. listen>( @@ -239,6 +254,8 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); }), ]); + // A link stored before the listener existed sent its event to nobody. + void unlisteners.then(() => openPendingAddInstance()); return () => { void unlisteners.then((fns) => fns.forEach((fn) => void fn())); diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts index d3a2f5d9..2d43b81c 100644 --- a/new-ui/src/shared/rust-api/api.ts +++ b/new-ui/src/shared/rust-api/api.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { mfaToApi } from '../utils/mfa'; import type { ActiveConnectionSummary, + AddInstanceEventPayload, AppConfig, AppConfigPatch, Connection, @@ -118,6 +119,9 @@ const setAutostartEnabled = (enabled: boolean): Promise => const getProvisioningConfig = (): Promise => invoke(TauriCommand.GetProvisioningConfig); +const takePendingAddInstance = (): Promise => + invoke(TauriCommand.TakePendingAddInstance); + const getPlatformHeader = (): Promise => invoke(TauriCommand.GetPlatformHeader); const getLatestAppVersion = (): Promise => @@ -372,6 +376,7 @@ export const api = { setAutostartEnabled, // Misc getProvisioningConfig, + takePendingAddInstance, getPlatformHeader, getLatestAppVersion, getClientBuildVersion, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index eb17ee50..9aec4a43 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -143,6 +143,7 @@ export const TauriCommand = { SetAutostartEnabled: 'set_autostart_enabled', // Misc GetProvisioningConfig: 'get_provisioning_config', + TakePendingAddInstance: 'take_pending_add_instance', GetPlatformHeader: 'get_platform_header', GetLatestAppVersion: 'get_latest_app_version', GetClientBuildVersion: 'get_client_build_version', diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index e08cbb52..1c08a184 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -96,6 +96,11 @@ ignore = [ { id = "RUSTSEC-2025-0081", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0098", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0100", reason = "Tauri v2 dependency (unmaintained)" }, + # zbus 5 support crates, merged into zbus 6; pulled by tauri-plugin-notification + # (notify-rust), tauri-plugin-opener and tauri-plugin-single-instance. + { id = "RUSTSEC-2026-0370", reason = "zvariant via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/src-tauri/enterprise/provisioning/src/lib.rs b/src-tauri/enterprise/provisioning/src/lib.rs index 221c30d3..6fcac805 100644 --- a/src-tauri/enterprise/provisioning/src/lib.rs +++ b/src-tauri/enterprise/provisioning/src/lib.rs @@ -1,4 +1,4 @@ -use std::{fmt, fs, path::Path}; +use std::{fmt, fs, io, path::Path}; use log::{debug, warn}; use serde::{Deserialize, Serialize}; @@ -66,6 +66,22 @@ pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option io::Result<()> { + let config_file_path = app_data_dir.join(CONFIG_FILE_NAME); + match fs::remove_file(&config_file_path) { + Ok(()) => { + debug!( + "Removed provisioning config file at {}", + config_file_path.display() + ); + Ok(()) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + #[cfg(test)] mod tests { use std::fs; @@ -94,6 +110,17 @@ mod tests { assert!(try_get_provisioning_config(dir.path()).is_none()); } + #[test] + fn test_clear_provisioning_config_removes_file_and_is_idempotent() { + let dir = tempdir().unwrap(); + let config_file = dir.path().join(CONFIG_FILE_NAME); + fs::write(&config_file, "{}").unwrap(); + + clear_provisioning_config(dir.path()).expect("config file should be removed"); + assert!(!config_file.exists()); + clear_provisioning_config(dir.path()).expect("missing config file should be ignored"); + } + #[test] fn test_malformed_json_returns_none() { let dir = tempdir().unwrap(); diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 0c0b6a3e..ff27fd24 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -57,6 +57,7 @@ commands.allow = [ "get_autostart_enabled", "set_autostart_enabled", "get_provisioning_config", + "take_pending_add_instance", "get_platform_header", "set_location_mfa_step_plan", "open_tray_window", diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index 5fc4309b..447f9f70 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -18,6 +18,7 @@ use uuid::Uuid; use crate::{ app_config::AppConfig, database::models::{connection::ActiveConnection, Id}, + events::AddInstancePayload, session_state::SessionState, utils::stats_handler, ConnectionType, @@ -43,6 +44,8 @@ pub struct AppState { pub tray_click_position: Mutex>>, stat_threads: Mutex>>, // location ID is the key pub provisioning_config: Mutex>, + pub provisioning_instance_id: Mutex>, + pub pending_add_instance: Mutex>, pub session_state: Mutex, } @@ -60,6 +63,8 @@ impl AppState { tray_click_position: Mutex::new(None), stat_threads: Mutex::new(HashMap::new()), provisioning_config: Mutex::new(provisioning_config), + provisioning_instance_id: Mutex::new(None), + pending_add_instance: Mutex::new(None), session_state: Mutex::new(SessionState::default()), } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a5246abd..880ee56f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,7 +29,7 @@ use defguard_client_proto::defguard::{ }, enterprise::posture::v2::DevicePostureData, }; -use defguard_client_provisioning::ProvisioningConfig; +use defguard_client_provisioning::{clear_provisioning_config, ProvisioningConfig}; use reqwest::Url; use serde::{Deserialize, Serialize}; use struct_patch::Patch; @@ -55,7 +55,7 @@ use crate::{ DB_POOL, }, error::Error, - events::{EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, + events::{AddInstancePayload, EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, fido2_window::{platform_context, WindowLevelGuard}, into_location, log_watcher::{ @@ -566,6 +566,45 @@ pub struct SaveDeviceConfigResponse { instance: Instance, } +/// Drops the in-memory provisioning config after any instance is saved, and removes +/// `provisioning.json` only when the saved instance was enrolled with its token. +/// Errors are logged, never returned, because the instance is already committed. +fn clear_provisioning_after_instance_save(handle: &AppHandle, saved_instance_uuid: &str) { + let app_state = handle.state::(); + match app_state.provisioning_config.lock() { + Ok(mut provisioning_config) => { + if provisioning_config.take().is_some() { + debug!("Cleared in-memory provisioning config after saving an instance"); + } + } + Err(_) => warn!("Failed to clear in-memory provisioning config"), + } + + let mut provisioning_instance_id = match app_state.provisioning_instance_id.lock() { + Ok(provisioning_instance_id) => provisioning_instance_id, + Err(_) => { + warn!("Failed to check provisioning instance id"); + return; + } + }; + if provisioning_instance_id.as_deref() == Some(saved_instance_uuid) { + match handle.path().app_data_dir() { + Ok(app_data_dir) => { + if let Err(err) = clear_provisioning_config(&app_data_dir) { + warn!( + "Failed to remove provisioning config file from {}: {err}", + app_data_dir.display() + ); + } + } + Err(err) => { + warn!("Failed to resolve app data directory for provisioning cleanup: {err}"); + } + } + *provisioning_instance_id = None; + } +} + #[tauri::command(async)] pub async fn save_device_config( private_key: String, @@ -623,6 +662,9 @@ pub async fn save_device_config( ); } transaction.commit().await?; + + clear_provisioning_after_instance_save(&handle, &instance.uuid); + info!("New instance {instance} created."); trace!("Created following instance: {instance:#?}"); @@ -1527,6 +1569,22 @@ pub fn get_provisioning_config( Ok(res) } +#[tauri::command] +pub fn take_pending_add_instance( + app_state: State<'_, AppState>, +) -> Result, Error> { + debug!("Running command take_pending_add_instance."); + let pending = app_state + .pending_add_instance + .lock() + .map_err(|_err| { + error!("Failed to acquire lock on pending add-instance deep link"); + Error::StateLockFail + })? + .take(); + Ok(pending) +} + #[tauri::command] #[must_use] pub fn get_platform_header() -> String { @@ -1589,6 +1647,15 @@ pub async fn enrollment_start( state: State<'_, AppState>, ) -> Result { debug!("Starting enrollment at {proxy_url}"); + let uses_provisioning_token = match state.provisioning_config.lock() { + Ok(provisioning_config) => provisioning_config + .as_ref() + .is_some_and(|config| config.enrollment_token == token), + Err(_) => { + warn!("Failed to read in-memory provisioning config"); + false + } + }; let url = Url::parse(&proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; let (session, response) = enrollment::enrollment_start(url, token) .await @@ -1605,20 +1672,36 @@ pub async fn enrollment_start( .as_ref() .map_or("", |u| u.login.as_str()); info!("Enrollment started for user {login}, session {session_id}"); + let provisioning_instance_id = response + .instance + .as_ref() + .map(|instance| instance.id.clone()); + let user = response + .user + .ok_or_else(|| "Proxy did not return user info".to_string())?; + let admin = response + .admin + .ok_or_else(|| "Proxy did not return admin info".to_string())?; + let settings = response + .settings + .ok_or_else(|| "Proxy did not return enrollment settings".to_string())?; + let instance = response + .instance + .ok_or_else(|| "Proxy did not return instance info".to_string())?; + if uses_provisioning_token { + if let Some(instance_id) = provisioning_instance_id { + match state.provisioning_instance_id.lock() { + Ok(mut provisioning_instance_id) => *provisioning_instance_id = Some(instance_id), + Err(_) => warn!("Failed to remember provisioning instance id"), + } + } + } Ok(EnrollmentStartResult { session_id, - user: response - .user - .ok_or_else(|| "Proxy did not return user info".to_string())?, - admin: response - .admin - .ok_or_else(|| "Proxy did not return admin info".to_string())?, - settings: response - .settings - .ok_or_else(|| "Proxy did not return enrollment settings".to_string())?, - instance: response - .instance - .ok_or_else(|| "Proxy did not return instance info".to_string())?, + user, + admin, + settings, + instance, deadline_timestamp: response.deadline_timestamp, final_page_content: response.final_page_content, }) diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index e01c6d4b..023b56f3 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -4,6 +4,7 @@ use tauri::{AppHandle, Emitter, Manager, Url}; use tauri_plugin_notification::NotificationExt; use crate::{ + appstate::AppState, commands::LocationInfo, database::models::{instance::InstanceInfo, location::LocationMfaMethod, Id}, window_manager::{WindowManager, COMPACT_WINDOW_ID}, @@ -65,9 +66,9 @@ impl DeadConnReconnected { } #[derive(Clone, Serialize)] -pub struct AddInstancePayload<'a> { - pub token: &'a str, - pub url: &'a str, +pub struct AddInstancePayload { + pub token: String, + pub url: String, } /// Payload for the `configure-factors-trigger` event. Mirrors `ConfigureFactorsPayload` in @@ -129,28 +130,30 @@ pub fn handle_deep_link(app_handle: &AppHandle, urls: &[Url]) { let mut token = None; let mut url = None; for (key, value) in link.query_pairs() { - if key == "token" { - token = Some(value.clone()); - } - if key == "url" { - url = Some(value.clone()); + match key.as_ref() { + "token" => token = Some(value.into_owned()), + "url" => url = Some(value.into_owned()), + _ => {} } } if let (Some(token), Some(url)) = (token, url) { info!("Valid Deep link received."); + let payload = AddInstancePayload { token, url }; + let app_state = app_handle.state::(); + if let Ok(mut pending) = app_state.pending_add_instance.lock() { + *pending = Some(payload.clone()); + } else { + error!("Failed to store pending add-instance deep link."); + } if let Some(tray_win) = app_handle.get_webview_window(COMPACT_WINDOW_ID) { let _ = tray_win.hide(); } if let Err(e) = WindowManager::open_full_view(app_handle) { warn!("Deep link: failed to open main window: {e}"); } - let _ = app_handle.emit( - EventKey::AddInstance.into(), - AddInstancePayload { - token: &token, - url: &url, - }, - ); + if let Err(err) = app_handle.emit(EventKey::AddInstance.into(), payload) { + error!("Event AddInstance was not emitted. Reason: {err}"); + } } } } diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 88aeeaae..695fd0f9 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -194,6 +194,7 @@ pub fn run_app() { command_get_app_config, command_set_app_config, get_provisioning_config, + take_pending_add_instance, get_platform_header, get_posture_data, set_location_mfa_step_plan, @@ -457,14 +458,11 @@ pub fn run_app() { // Decide which window to show based on available locations. // If the app was cold-launched by a deep-link, the full view must open, not the // tray. - let launched_by_deep_link = app_handle - .deep_link() - .get_current() - .ok() - .flatten() - .is_some(); - if launched_by_deep_link { + let startup_deep_link = app_handle.deep_link().get_current().ok().flatten(); + if let Some(urls) = startup_deep_link { info!("App launched via deep link, opening full view directly."); + // Store the link before the full view loads, so its first route load takes it. + handle_deep_link(app_handle, &urls); let _ = WindowManager::open_full_view(app_handle); } else if open_welcome_view { info!("Opening welcome view."); @@ -514,11 +512,6 @@ pub fn run_app() { ); async_runtime::block_on(startup(app_handle)); - // Handle a deep link that launched the app (startup case). - if let Ok(Some(urls)) = app_handle.deep_link().get_current() { - handle_deep_link(app_handle, &urls); - } - // Handle Ctrl-C. debug!("Setting up Ctrl-C handler."); let app_handle_clone = app_handle.clone();