diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 000000000..34b8762d0 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,119 @@ +version: "3" + +env: + SQLX_OFFLINE: "true" + +tasks: + dev: + cmds: + - npx concurrently -n "NEW,TAURI" "cd new-ui && pnpm dev" "cargo tauri dev" + + dev-tauri: + desc: Run only the Tauri side with the local dev database and debug logging. + dir: src-tauri + env: + DATABASE_URL: sqlite:dev.db + DEFGUARD_CLIENT_DEV: "1" + DEFGUARD_CLIENT_LOG_LEVEL: debug + cmds: + - cargo tauri dev + + dev-web: + desc: Run only the web frontend dev server. + dir: new-ui + cmds: + - pnpm dev + + build: + cmds: + - cd new-ui && pnpm build + - cargo tauri build --config src-tauri/tauri.local.conf.json + + lint: + desc: Format and lint the Rust workspace and the UI. + cmds: + - task: lint-rust + - task: lint-ui + + lint-rust: + desc: Format and lint the Rust workspace in src-tauri. + dir: src-tauri + cmds: + - cargo fmt --all + - cargo clippy --workspace --all-targets --all-features -- -D warnings + + test-rust: + desc: Run the Rust workspace tests in src-tauri with nextest. Extra args go after --. + dir: src-tauri + cmds: + - cargo nextest run --workspace --no-fail-fast {{.CLI_ARGS}} + + query-data: + desc: Recreate a throwaway sqlite db from migrations and regenerate the sqlx offline query data in src-tauri/.sqlx. + dir: src-tauri + env: + DATABASE_URL: sqlite:{{.TASKFILE_DIR}}/src-tauri/query-data.db + SQLX_OFFLINE: "false" + cmds: + - sqlx database drop -y + - sqlx database create + - sqlx migrate run --source migrations + - cargo sqlx prepare --workspace -- --workspace --all-targets --all-features + + lint-ui: + desc: Format and lint the UI in new-ui. + dir: new-ui + cmds: + - pnpm fix + - pnpm lint + + test-ui: + desc: Run the UI unit tests in new-ui with vitest. Extra args go after --. + dir: new-ui + cmds: + - pnpm test {{.CLI_ARGS}} + + e2e-build: + desc: Build the client and install both frontend dependency sets. + platforms: [linux, darwin] + cmds: + - cd new-ui && pnpm install --frozen-lockfile && pnpm build + - cd e2e && pnpm install --frozen-lockfile + - cargo tauri build + + e2e-provision: + desc: Provision the configured Core deployment. It must already be running. + platforms: [linux, darwin] + dir: e2e + preconditions: + - sh: test -f .env + msg: Missing e2e/.env, copy e2e/.env.example and fill it locally. + cmds: + - pnpm provision + + e2e-test: + desc: Run the WebDriver suite. Core, Edge, gateway and defguard-service are external prerequisites. + platforms: [linux, darwin] + dir: e2e + preconditions: + - sh: test -f .env + msg: Missing e2e/.env, copy e2e/.env.example and fill it locally. + - sh: test -S /var/run/defguard.socket + msg: defguard-service is not running at /var/run/defguard.socket. + - sh: test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" + msg: tauri-driver is missing, set TAURI_DRIVER to its location. + - sh: command -v WebKitWebDriver + msg: WebKitWebDriver is missing, enter nix develop. + - sh: command -v xvfb-run + msg: xvfb-run is missing, enter nix develop. + - sh: command -v dbus-run-session + msg: dbus-run-session is missing, enter nix develop. + cmds: + - NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test + + e2e: + platforms: [linux, darwin] + cmds: + - task: e2e-build + - task: e2e-provision + - task: e2e-test diff --git a/justfile b/justfile index 06c1fc630..8017f0153 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,7 @@ set windows-shell := ["powershell.exe", "-c"] +export SQLX_OFFLINE := "true" + dev: npx concurrently \ -n "NEW,TAURI" \ diff --git a/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/OverviewSelection.tsx b/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/OverviewSelection.tsx index be0adddd6..7d67222c5 100644 --- a/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/OverviewSelection.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/OverviewSelection.tsx @@ -1,13 +1,17 @@ import './style.scss'; import clsx from 'clsx'; -import { TruncatedText } from '../../../../../shared/components/TruncatedText/TruncatedText'; import { useAppData } from '../../../../../shared/providers/AppDataContext'; +import { TooltipContent } from '../../../../../shared/providers/tooltip/TooltipContent'; +import { TooltipProvider } from '../../../../../shared/providers/tooltip/TooltipContext'; +import { TooltipTrigger } from '../../../../../shared/providers/tooltip/TooltipTrigger'; import type { InstanceInfo, LocationInfo, OverviewViewSelection, } from '../../../../../shared/rust-api/types'; +const labelTooltipOpenDelayMs = 1000; + type Props = { instances: InstanceInfo[]; tunnels: LocationInfo[]; @@ -20,9 +24,16 @@ type SelectionItemProps = { }; const SelectionItem = ({ label, selected, onClick }: SelectionItemProps) => ( - + + + + + +

{label}

+
+
); export const OverviewSelection = ({ instances, tunnels }: Props) => { diff --git a/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/style.scss b/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/style.scss index 3898e4832..534cda255 100644 --- a/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/style.scss +++ b/new-ui/src/pages/full/OverviewPage/components/OverviewSelection/style.scss @@ -50,6 +50,10 @@ > .label { font: var(--t-body-sm-400); color: inherit; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } &:not(.selected):hover { diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/AutoConnectSection.tsx b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/AutoConnectSection.tsx new file mode 100644 index 000000000..75b781581 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/AutoConnectSection.tsx @@ -0,0 +1,102 @@ +import './style.scss'; +import { type UseQueryResult, useMutation, useQueries } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { Checkbox } from '../../../../../shared/components/Checkbox/Checkbox'; +import { Divider } from '../../../../../shared/components/Divider/Divider'; +import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox'; +import { useAppData } from '../../../../../shared/providers/AppDataContext'; +import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar'; +import { api } from '../../../../../shared/rust-api/api'; +import { getLocationsQueryOptions } from '../../../../../shared/rust-api/query'; +import type { LocationInfo } from '../../../../../shared/rust-api/types'; +import { ThemeSpacing } from '../../../../../shared/types'; +import { AutoConnectInstanceCard } from './components/AutoConnectInstanceCard/AutoConnectInstanceCard'; +import { buildAutoConnectCards } from './helpers'; + +type Props = { + launchAtLogin: boolean; +}; + +type ToggleArgs = { + location: LocationInfo; + autoConnect: boolean; +}; + +const combineLocations = (results: UseQueryResult[]) => + results.map((result) => result.data ?? []); + +export const AutoConnectSection = ({ launchAtLogin }: Props) => { + const { instances } = useAppData(); + const [eligibleOnly, setEligibleOnly] = useState(true); + const locationsPerInstance = useQueries({ + queries: instances.map((instance) => getLocationsQueryOptions(instance.id)), + combine: combineLocations, + }); + + const { mutate: setAutoConnect, isPending } = useMutation({ + mutationFn: ({ location, autoConnect }: ToggleArgs) => + api.setLocationAutoConnect({ locationId: location.id, autoConnect }), + meta: { invalidate: ['locations'] }, + onError: (_error, { location }) => { + Snackbar.error(`Couldn't change auto-connect for ${location.name}.`); + }, + }); + + const { cards, eligibleCards } = useMemo( + () => buildAutoConnectCards(instances, locationsPerInstance), + [instances, locationsPerInstance], + ); + const showFilter = eligibleCards.length > 0; + const filterApplied = showFilter && eligibleOnly; + const visibleCards = filterApplied ? eligibleCards : cards; + + if (cards.length === 0) return null; + + return ( +
+ +

Auto-connect locations

+ +

+ Select the locations to connect automatically when the Defguard client starts.{' '} + + Locations with MFA can't auto-connect. Only one All traffic location can + auto-connect, across all instances. + +

+ {!launchAtLogin && ( + <> + +

+ Defguard doesn't start with your operating system, so these locations connect + only when you open the client. +

+ + )} + + {showFilter && ( + <> + setEligibleOnly((value) => !value)} + /> + + + )} +
+ {visibleCards.map(({ instance, locations }) => ( + + setAutoConnect({ location, autoConnect: !location.auto_connect }) + } + /> + ))} +
+
+ ); +}; diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/AutoConnectInstanceCard.tsx b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/AutoConnectInstanceCard.tsx new file mode 100644 index 000000000..95eb0acaa --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/AutoConnectInstanceCard.tsx @@ -0,0 +1,101 @@ +import './style.scss'; +import { Badge } from '../../../../../../../shared/components/Badge/Badge'; +import { Divider } from '../../../../../../../shared/components/Divider/Divider'; +import { IconKind } from '../../../../../../../shared/components/Icon'; +import { Toggle } from '../../../../../../../shared/components/Toggle/Toggle'; +import { TooltipContent } from '../../../../../../../shared/providers/tooltip/TooltipContent'; +import { TooltipProvider } from '../../../../../../../shared/providers/tooltip/TooltipContext'; +import { TooltipTrigger } from '../../../../../../../shared/providers/tooltip/TooltipTrigger'; +import { + AutoConnectBlock, + type LocationInfo, +} from '../../../../../../../shared/rust-api/types'; +import { ThemeSpacing } from '../../../../../../../shared/types'; +import { isPresent } from '../../../../../../../shared/utils/isPresent'; + +type Props = { + name: string; + locations: LocationInfo[]; + disabled: boolean; + onToggle: (location: LocationInfo) => void; +}; + +export const AutoConnectInstanceCard = ({ + name, + locations, + disabled, + onToggle, +}: Props) => { + return ( +
+

{name}

+ +
+ {locations.map((location) => ( + onToggle(location)} + /> + ))} +
+
+ ); +}; + +type LocationRowProps = { + location: LocationInfo; + disabled: boolean; + onToggle: () => void; +}; + +const LocationRow = ({ location, disabled, onToggle }: LocationRowProps) => { + const blocked = location.auto_connect_blocked; + + return ( +
+ + {blocked === AutoConnectBlock.Mfa && ( + + )} + {blocked === AutoConnectBlock.AllTrafficTaken && ( + + )} + {!isPresent(blocked) && ( + + )} +
+ ); +}; + +type LockedBadgeProps = { + text: string; + tooltip: string; +}; + +const LockedBadge = ({ text, tooltip }: LockedBadgeProps) => { + return ( + + + + + +

{tooltip}

+
+
+ ); +}; diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/style.scss b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/style.scss new file mode 100644 index 000000000..e4ac682d8 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/components/AutoConnectInstanceCard/style.scss @@ -0,0 +1,37 @@ +.auto-connect-instance-card { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + padding: var(--spacing-lg); + box-sizing: border-box; + border-radius: var(--radius-lg); + background-color: var(--bg-white-5); + + & > .title { + font: var(--t-body-sm-400); + color: var(--fg-white-80); + } + + & > .locations { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + row-gap: var(--spacing-sm); + + & > .location { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + column-gap: var(--spacing-md); + padding: var(--spacing-sm); + box-sizing: border-box; + + & > .badge { + --font: var(--t-body-xs-400); + } + } + } +} diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.test.ts b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.test.ts new file mode 100644 index 000000000..c660e3cb5 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + AutoConnectBlock, + type AutoConnectBlockValue, + type InstanceInfo, + type LocationInfo, +} from '../../../../../shared/rust-api/types'; +import { buildAutoConnectCards } from './helpers'; + +const instanceOf = (id: number) => ({ id, name: `instance-${id}` }) as InstanceInfo; + +const locationOf = ( + id: number, + auto_connect_blocked: AutoConnectBlockValue | null = null, +) => ({ id, auto_connect_blocked }) as LocationInfo; + +const idsOf = (cards: { instance: InstanceInfo; locations: LocationInfo[] }[]) => + cards.map(({ instance, locations }) => [instance.id, locations.map(({ id }) => id)]); + +describe('buildAutoConnectCards', () => { + it('drops instances without locations, including ones still loading', () => { + const { cards } = buildAutoConnectCards( + [instanceOf(1), instanceOf(2), instanceOf(3)], + [[locationOf(10)], []], + ); + expect(idsOf(cards)).toEqual([[1, [10]]]); + }); + + it('keeps locations blocked by all traffic as eligible', () => { + const { eligibleCards } = buildAutoConnectCards( + [instanceOf(1)], + [[locationOf(10), locationOf(11, AutoConnectBlock.AllTrafficTaken)]], + ); + expect(idsOf(eligibleCards)).toEqual([[1, [10, 11]]]); + }); + + it('leaves MFA locations out of the eligible cards', () => { + const { cards, eligibleCards } = buildAutoConnectCards( + [instanceOf(1), instanceOf(2)], + [ + [locationOf(10), locationOf(11, AutoConnectBlock.Mfa)], + [locationOf(20, AutoConnectBlock.Mfa)], + ], + ); + expect(idsOf(cards)).toEqual([ + [1, [10, 11]], + [2, [20]], + ]); + expect(idsOf(eligibleCards)).toEqual([[1, [10]]]); + }); +}); diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.ts b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.ts new file mode 100644 index 000000000..6dad5aca3 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/helpers.ts @@ -0,0 +1,33 @@ +import { + AutoConnectBlock, + type InstanceInfo, + type LocationInfo, +} from '../../../../../shared/rust-api/types'; + +export type AutoConnectCard = { + instance: InstanceInfo; + locations: LocationInfo[]; +}; + +const isEligible = (location: LocationInfo) => + location.auto_connect_blocked !== AutoConnectBlock.Mfa && + location.auto_connect_blocked !== AutoConnectBlock.ServiceLocation; + +const withLocations = (cards: AutoConnectCard[]) => + cards.filter(({ locations }) => locations.length > 0); + +export const buildAutoConnectCards = ( + instances: InstanceInfo[], + locationsPerInstance: LocationInfo[][], +) => { + const cards = withLocations( + instances.map((instance, index) => ({ + instance, + locations: locationsPerInstance[index] ?? [], + })), + ); + const eligibleCards = withLocations( + cards.map((card) => ({ ...card, locations: card.locations.filter(isEligible) })), + ); + return { cards, eligibleCards }; +}; diff --git a/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/style.scss b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/style.scss new file mode 100644 index 000000000..8a08cb252 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/components/AutoConnectSection/style.scss @@ -0,0 +1,33 @@ +.auto-connect-section { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + + & > .title { + font: var(--t-body-sm-500); + color: var(--fg-white-100); + } + + & > .description { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + + & > span { + color: var(--fg-white-100); + } + } + + & > .hint { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + + & > .instances { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + row-gap: var(--spacing-lg); + } +} diff --git a/new-ui/src/pages/full/SettingsPage/tabs/StartupTab/StartupTab.tsx b/new-ui/src/pages/full/SettingsPage/tabs/StartupTab/StartupTab.tsx index e67331772..e1e1aaed0 100644 --- a/new-ui/src/pages/full/SettingsPage/tabs/StartupTab/StartupTab.tsx +++ b/new-ui/src/pages/full/SettingsPage/tabs/StartupTab/StartupTab.tsx @@ -5,6 +5,7 @@ import { api } from '../../../../../shared/rust-api/api'; import { getAutostartEnabledQueryOptions } from '../../../../../shared/rust-api/query'; import type { AppConfig } from '../../../../../shared/rust-api/types'; import { ThemeSpacing } from '../../../../../shared/types'; +import { AutoConnectSection } from '../../components/AutoConnectSection/AutoConnectSection'; import { usePatchAppConfig } from '../../hooks/usePatchAppConfig'; type Props = { @@ -56,6 +57,7 @@ export const StartupTab = ({ appConfig }: Props) => { patchConfig({ open_in_full_window: !appConfig.open_in_full_window }) } /> + ); }; diff --git a/new-ui/src/shared/components/Badge/Badge.tsx b/new-ui/src/shared/components/Badge/Badge.tsx index 4e48d7016..a47e7c21a 100644 --- a/new-ui/src/shared/components/Badge/Badge.tsx +++ b/new-ui/src/shared/components/Badge/Badge.tsx @@ -1,20 +1,34 @@ import './style.scss'; import clsx from 'clsx'; -import type { HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; +import { isPresent } from '../../utils/isPresent'; +import { Icon, type IconKindValue } from '../Icon'; type Props = { text: string; + icon?: IconKindValue; disabled?: boolean; testId?: string; + ref?: Ref; } & HTMLAttributes; -export const Badge = ({ text, testId, disabled = false, className, ...props }: Props) => { +export const Badge = ({ + text, + icon, + testId, + ref, + disabled = false, + className, + ...props +}: Props) => { return (
+ {isPresent(icon) && } {text}
); diff --git a/new-ui/src/shared/components/Badge/style.scss b/new-ui/src/shared/components/Badge/style.scss index 87e4ec25d..897c450f7 100644 --- a/new-ui/src/shared/components/Badge/style.scss +++ b/new-ui/src/shared/components/Badge/style.scss @@ -1,11 +1,14 @@ .badge { --bg-color: var(--bg-white-10); --text-color: var(--fg-white-100); + --icon-color: var(--text-color); + --font: var(--t-badge); display: inline-flex; flex-flow: row nowrap; align-items: center; justify-content: center; + column-gap: var(--badge-gap); height: var(--badge-height); padding: 0 var(--badge-spacing); box-sizing: border-box; @@ -21,7 +24,7 @@ } & > span { - font: var(--t-badge); + font: var(--font); color: var(--text-color); white-space: nowrap; diff --git a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx index f70750e46..121178450 100644 --- a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx +++ b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx @@ -1,6 +1,5 @@ -import { useMutation } from '@tanstack/react-query'; import { Fragment } from 'react/jsx-runtime'; -import { api } from '../../../../rust-api/api'; +import { useUpdateLocationRouting } from '../../../../hooks/useUpdateLocationRouting'; import { ClientTrafficPolicy } from '../../../../rust-api/types'; import { ThemeSpacing } from '../../../../types'; import { shouldStartMfa } from '../../../../utils/mfa'; @@ -15,12 +14,7 @@ import { LocationCardViews } from '../../context/types'; export const DefaultView = () => { const { location, instance, setView, connectionAbility } = useLocationCardContext(); - const { mutate: updateRouting } = useMutation({ - mutationFn: api.updateLocationRouting, - meta: { - invalidate: ['locations'], - }, - }); + const { mutate: updateRouting } = useUpdateLocationRouting(location.name); return (
diff --git a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx index 5bcc0b616..1d27ff831 100644 --- a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx +++ b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx @@ -11,6 +11,7 @@ import { import { useConnectModal } from '../../../pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal'; import { useConfigureFactorsScreen } from '../../hooks/useConfigureFactorsScreen'; import { useConnectionAbility } from '../../hooks/useConnectionAbility'; +import { useUpdateLocationRouting } from '../../hooks/useUpdateLocationRouting'; import { api } from '../../rust-api/api'; import { getAppConfigQueryOptions } from '../../rust-api/query'; import type { InstanceInfo, LocationInfo } from '../../rust-api/types'; @@ -41,12 +42,7 @@ interface Props { export const OverviewLocationCard = ({ location, instance }: Props) => { const navigate = useNavigate(); const { data: appConfig } = useQuery(getAppConfigQueryOptions); - const { mutate: updateRouting } = useMutation({ - mutationFn: api.updateLocationRouting, - meta: { - invalidate: ['locations'], - }, - }); + const { mutate: updateRouting } = useUpdateLocationRouting(location.name); const { mutate: connect, isPending: isConnecting } = useMutation({ mutationFn: api.connect, diff --git a/new-ui/src/shared/hooks/useUpdateLocationRouting.ts b/new-ui/src/shared/hooks/useUpdateLocationRouting.ts new file mode 100644 index 000000000..f9093ebf7 --- /dev/null +++ b/new-ui/src/shared/hooks/useUpdateLocationRouting.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { Snackbar } from '../providers/snackbar/snackbar'; +import { api } from '../rust-api/api'; + +export const useUpdateLocationRouting = (locationName: string) => + useMutation({ + mutationFn: api.updateLocationRouting, + meta: { + invalidate: ['locations'], + }, + onSuccess: (autoConnectDisabled) => { + if (autoConnectDisabled) { + Snackbar.default(`Auto-connect turned off for ${locationName}`); + } + }, + }); diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts index 2d43b81c3..3a1629804 100644 --- a/new-ui/src/shared/rust-api/api.ts +++ b/new-ui/src/shared/rust-api/api.ts @@ -5,6 +5,7 @@ import type { AddInstanceEventPayload, AppConfig, AppConfigPatch, + AutoConnectDisabled, Connection, ConnectionArgs, CreateDeviceResponse, @@ -28,6 +29,7 @@ import type { SaveDeviceConfigResponse, SessionState, SessionStatePatch, + SetLocationAutoConnectArgs, SetLocationMfaStepPlanArgs, StatsArgs, TunnelInfo, @@ -57,12 +59,15 @@ const hasAnyVisibleLocations = (): Promise => const getLocationDetails = (args: LocationDetailsArgs): Promise => invoke(TauriCommand.LocationInterfaceDetails, args); -const updateLocationRouting = (args: RoutingArgs): Promise => +const updateLocationRouting = (args: RoutingArgs): Promise => invoke(TauriCommand.UpdateLocationRouting, args); const setLocationMfaStepPlan = (args: SetLocationMfaStepPlanArgs): Promise => invoke(TauriCommand.SetLocationMfaStepPlan, args); +const setLocationAutoConnect = (args: SetLocationAutoConnectArgs): Promise => + invoke(TauriCommand.SetLocationAutoConnect, args); + const connect = (args: ConnectionArgs): Promise => invoke(TauriCommand.Connect, args); @@ -353,6 +358,7 @@ export const api = { getLocationDetails, updateLocationRouting, setLocationMfaStepPlan, + setLocationAutoConnect, // Connections connect, checkRouteConflict, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index 90de4dae5..10998dd1d 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -120,6 +120,7 @@ export const TauriCommand = { LocationInterfaceDetails: 'location_interface_details', UpdateLocationRouting: 'update_location_routing', SetLocationMfaStepPlan: 'set_location_mfa_step_plan', + SetLocationAutoConnect: 'set_location_auto_connect', // Connections Connect: 'connect', CheckRouteConflict: 'check_route_conflict', @@ -341,8 +342,21 @@ export type LocationInfo = { posture_check_required: boolean; mfa_steps: MfaStep[]; mfa_step_plan: MfaMethodValue[]; + auto_connect: boolean; + auto_connect_blocked: AutoConnectBlockValue | null; + /** Effective under the instance's traffic policy, unlike `route_all_traffic`. */ + routes_all_traffic: boolean; }; +export const AutoConnectBlock = { + Mfa: 'mfa', + ServiceLocation: 'service_location', + AllTrafficTaken: 'all_traffic_taken', +} as const; + +export type AutoConnectBlockValue = + (typeof AutoConnectBlock)[keyof typeof AutoConnectBlock]; + export type LocationStats = { collected_at: number; download: number; @@ -498,6 +512,13 @@ export type SetLocationMfaStepPlanArgs = { mfaStepPlan: MfaMethodValue[]; }; +export type SetLocationAutoConnectArgs = { + locationId: number; + autoConnect: boolean; +}; + +export type AutoConnectDisabled = boolean; + export type OverviewViewSelection = { kind: 'instance' | 'tunnel'; id: number; diff --git a/src-tauri/.sqlx/query-7e54c1ca537f80df1a3737b5f8b2923078ea9193eaadff9ffb43b4f8b24493b7.json b/src-tauri/.sqlx/query-326dcb15f496412bfcf356be9bb8a9f01702f9b715252dc83bcac3444ca0cf68.json similarity index 72% rename from src-tauri/.sqlx/query-7e54c1ca537f80df1a3737b5f8b2923078ea9193eaadff9ffb43b4f8b24493b7.json rename to src-tauri/.sqlx/query-326dcb15f496412bfcf356be9bb8a9f01702f9b715252dc83bcac3444ca0cf68.json index ae0a487ad..af9478f0c 100644 --- a/src-tauri/.sqlx/query-7e54c1ca537f80df1a3737b5f8b2923078ea9193eaadff9ffb43b4f8b24493b7.json +++ b/src-tauri/.sqlx/query-326dcb15f496412bfcf356be9bb8a9f01702f9b715252dc83bcac3444ca0cf68.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14, mfa_steps = $15, mfa_step_plan = $16, client_mtu = $17 WHERE id = $18", + "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14, mfa_steps = $15, mfa_step_plan = $16, client_mtu = $17, auto_connect = $18 WHERE id = $19", "describe": { "columns": [], "parameters": { - "Right": 18 + "Right": 19 }, "nullable": [] }, - "hash": "7e54c1ca537f80df1a3737b5f8b2923078ea9193eaadff9ffb43b4f8b24493b7" + "hash": "326dcb15f496412bfcf356be9bb8a9f01702f9b715252dc83bcac3444ca0cf68" } diff --git a/src-tauri/.sqlx/query-18bb20a2fcb95b953a8e3f6414db44ca47231624d1b96199312bd6472d16f3b2.json b/src-tauri/.sqlx/query-33c9d4d98915dcf3790c742852675b466f3bc5df9926c82bac77eb0ac7d36a64.json similarity index 91% rename from src-tauri/.sqlx/query-18bb20a2fcb95b953a8e3f6414db44ca47231624d1b96199312bd6472d16f3b2.json rename to src-tauri/.sqlx/query-33c9d4d98915dcf3790c742852675b466f3bc5df9926c82bac77eb0ac7d36a64.json index 9c6cebf50..42c19fcef 100644 --- a/src-tauri/.sqlx/query-18bb20a2fcb95b953a8e3f6414db44ca47231624d1b96199312bd6472d16f3b2.json +++ b/src-tauri/.sqlx/query-33c9d4d98915dcf3790c742852675b466f3bc5df9926c82bac77eb0ac7d36a64.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -200,6 +200,17 @@ "name": "client_mtu" } } + }, + { + "name": "auto_connect", + "ordinal": 18, + "type_info": "Bool", + "origin": { + "Table": { + "table": "location", + "name": "auto_connect" + } + } } ], "parameters": { @@ -223,8 +234,9 @@ false, false, false, - true + true, + false ] }, - "hash": "18bb20a2fcb95b953a8e3f6414db44ca47231624d1b96199312bd6472d16f3b2" + "hash": "33c9d4d98915dcf3790c742852675b466f3bc5df9926c82bac77eb0ac7d36a64" } diff --git a/src-tauri/.sqlx/query-766a508701f5adbc267309df525950925ded99e8c9de2caf320d379a04bab174.json b/src-tauri/.sqlx/query-3f4eec5c0ef0dcbfbe29525a3fca6f79e6f36b5461856a037d758499440e9836.json similarity index 91% rename from src-tauri/.sqlx/query-766a508701f5adbc267309df525950925ded99e8c9de2caf320d379a04bab174.json rename to src-tauri/.sqlx/query-3f4eec5c0ef0dcbfbe29525a3fca6f79e6f36b5461856a037d758499440e9836.json index a8c175c7b..a079386ad 100644 --- a/src-tauri/.sqlx/query-766a508701f5adbc267309df525950925ded99e8c9de2caf320d379a04bab174.json +++ b/src-tauri/.sqlx/query-3f4eec5c0ef0dcbfbe29525a3fca6f79e6f36b5461856a037d758499440e9836.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -200,6 +200,17 @@ "name": "client_mtu" } } + }, + { + "name": "auto_connect", + "ordinal": 18, + "type_info": "Bool", + "origin": { + "Table": { + "table": "location", + "name": "auto_connect" + } + } } ], "parameters": { @@ -223,8 +234,9 @@ false, false, false, - true + true, + false ] }, - "hash": "766a508701f5adbc267309df525950925ded99e8c9de2caf320d379a04bab174" + "hash": "3f4eec5c0ef0dcbfbe29525a3fca6f79e6f36b5461856a037d758499440e9836" } diff --git a/src-tauri/.sqlx/query-b6174054c5b7f98db26bf5e3b06bc496a0273d1204fb3de1d2a4114c1f5c66f4.json b/src-tauri/.sqlx/query-50a7c440b7e88ba07b114eb97f94fab9dd4f6e2e4f3e46ab7a0ba26b22c300c3.json similarity index 92% rename from src-tauri/.sqlx/query-b6174054c5b7f98db26bf5e3b06bc496a0273d1204fb3de1d2a4114c1f5c66f4.json rename to src-tauri/.sqlx/query-50a7c440b7e88ba07b114eb97f94fab9dd4f6e2e4f3e46ab7a0ba26b22c300c3.json index 8d6b905e3..a881532d4 100644 --- a/src-tauri/.sqlx/query-b6174054c5b7f98db26bf5e3b06bc496a0273d1204fb3de1d2a4114c1f5c66f4.json +++ b/src-tauri/.sqlx/query-50a7c440b7e88ba07b114eb97f94fab9dd4f6e2e4f3e46ab7a0ba26b22c300c3.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" FROM location WHERE id = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect FROM location WHERE id = $1", "describe": { "columns": [ { @@ -200,6 +200,17 @@ "name": "client_mtu" } } + }, + { + "name": "auto_connect", + "ordinal": 18, + "type_info": "Bool", + "origin": { + "Table": { + "table": "location", + "name": "auto_connect" + } + } } ], "parameters": { @@ -223,8 +234,9 @@ false, false, false, - true + true, + false ] }, - "hash": "b6174054c5b7f98db26bf5e3b06bc496a0273d1204fb3de1d2a4114c1f5c66f4" + "hash": "50a7c440b7e88ba07b114eb97f94fab9dd4f6e2e4f3e46ab7a0ba26b22c300c3" } diff --git a/src-tauri/.sqlx/query-c84402221aac288e3d4c82df9011dd9954ea990b0d9ff60bc59c56571ae0b430.json b/src-tauri/.sqlx/query-770585ca1951a3c647bacf3503284c87bc90e54a3bc985294bf1076de892c986.json similarity index 91% rename from src-tauri/.sqlx/query-c84402221aac288e3d4c82df9011dd9954ea990b0d9ff60bc59c56571ae0b430.json rename to src-tauri/.sqlx/query-770585ca1951a3c647bacf3503284c87bc90e54a3bc985294bf1076de892c986.json index aba15807d..f37811d60 100644 --- a/src-tauri/.sqlx/query-c84402221aac288e3d4c82df9011dd9954ea990b0d9ff60bc59c56571ae0b430.json +++ b/src-tauri/.sqlx/query-770585ca1951a3c647bacf3503284c87bc90e54a3bc985294bf1076de892c986.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" FROM location WHERE pubkey = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect FROM location WHERE pubkey = $1", "describe": { "columns": [ { @@ -200,6 +200,17 @@ "name": "client_mtu" } } + }, + { + "name": "auto_connect", + "ordinal": 18, + "type_info": "Bool", + "origin": { + "Table": { + "table": "location", + "name": "auto_connect" + } + } } ], "parameters": { @@ -223,8 +234,9 @@ false, false, false, - true + true, + false ] }, - "hash": "c84402221aac288e3d4c82df9011dd9954ea990b0d9ff60bc59c56571ae0b430" + "hash": "770585ca1951a3c647bacf3503284c87bc90e54a3bc985294bf1076de892c986" } diff --git a/src-tauri/.sqlx/query-c1a2d067cf1cfbf63bec70e2b78e2c11eb8f2813cdb2a832b7e39442cec03cf8.json b/src-tauri/.sqlx/query-7dbb734fab46e61c485060f6d7157f6a79d90ed15d194893243e241f24cba781.json similarity index 91% rename from src-tauri/.sqlx/query-c1a2d067cf1cfbf63bec70e2b78e2c11eb8f2813cdb2a832b7e39442cec03cf8.json rename to src-tauri/.sqlx/query-7dbb734fab46e61c485060f6d7157f6a79d90ed15d194893243e241f24cba781.json index a38c57e48..39fe47800 100644 --- a/src-tauri/.sqlx/query-c1a2d067cf1cfbf63bec70e2b78e2c11eb8f2813cdb2a832b7e39442cec03cf8.json +++ b/src-tauri/.sqlx/query-7dbb734fab46e61c485060f6d7157f6a79d90ed15d194893243e241f24cba781.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", "describe": { "columns": [ { @@ -200,6 +200,17 @@ "name": "client_mtu" } } + }, + { + "name": "auto_connect", + "ordinal": 18, + "type_info": "Bool", + "origin": { + "Table": { + "table": "location", + "name": "auto_connect" + } + } } ], "parameters": { @@ -223,8 +234,9 @@ false, false, false, - true + true, + false ] }, - "hash": "c1a2d067cf1cfbf63bec70e2b78e2c11eb8f2813cdb2a832b7e39442cec03cf8" + "hash": "7dbb734fab46e61c485060f6d7157f6a79d90ed15d194893243e241f24cba781" } diff --git a/src-tauri/.sqlx/query-3b43eb0944b7bf211eb46665bc5c7c705bd03f9c3a53ef0bf3def39548a2caf4.json b/src-tauri/.sqlx/query-d1e077d58b8a5b35c623e93694c2b3c0a71aa00d9bddc633524831253db21437.json similarity index 72% rename from src-tauri/.sqlx/query-3b43eb0944b7bf211eb46665bc5c7c705bd03f9c3a53ef0bf3def39548a2caf4.json rename to src-tauri/.sqlx/query-d1e077d58b8a5b35c623e93694c2b3c0a71aa00d9bddc633524831253db21437.json index c5a1d2f16..bc9c288f2 100644 --- a/src-tauri/.sqlx/query-3b43eb0944b7bf211eb46665bc5c7c705bd03f9c3a53ef0bf3def39548a2caf4.json +++ b/src-tauri/.sqlx/query-d1e077d58b8a5b35c623e93694c2b3c0a71aa00d9bddc633524831253db21437.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required, mfa_steps, mfa_step_plan, client_mtu) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id \"id!\"", + "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required, mfa_steps, mfa_step_plan, client_mtu, auto_connect) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id \"id!\"", "describe": { "columns": [ { @@ -16,11 +16,11 @@ } ], "parameters": { - "Right": 17 + "Right": 18 }, "nullable": [ null ] }, - "hash": "3b43eb0944b7bf211eb46665bc5c7c705bd03f9c3a53ef0bf3def39548a2caf4" + "hash": "d1e077d58b8a5b35c623e93694c2b3c0a71aa00d9bddc633524831253db21437" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90ea3517a..1c0ee296f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1547,6 +1547,7 @@ dependencies = [ "regex", "reqwest 0.13.5", "rust-ini", + "same-file", "serde", "serde_json", "serde_with", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 19d7b1805..4d5b0efce 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ qrcode = { version = "0.14", features = ["image"] } regex = "1.13" reqwest = { version = "0.13", features = ["cookies", "json"] } rust-ini = "0.21" +same-file = "1.0" secrecy = "0.10" semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } @@ -144,6 +145,7 @@ prost.workspace = true regex.workspace = true reqwest.workspace = true rust-ini.workspace = true +same-file.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true diff --git a/src-tauri/client-cli/src/commands/list.rs b/src-tauri/client-cli/src/commands/list.rs index 665b63703..bfe0b10ab 100644 --- a/src-tauri/client-cli/src/commands/list.rs +++ b/src-tauri/client-cli/src/commands/list.rs @@ -251,6 +251,7 @@ mod tests { mfa_steps: Json::default(), mfa_step_plan: Json::default(), client_mtu: None, + auto_connect: false, } } diff --git a/src-tauri/client-cli/src/commands/location.rs b/src-tauri/client-cli/src/commands/location.rs index 75ba9aade..289dc9434 100644 --- a/src-tauri/client-cli/src/commands/location.rs +++ b/src-tauri/client-cli/src/commands/location.rs @@ -120,8 +120,12 @@ pub async fn handle_set( } if let Some(true) = route_all_traffic { - Location::update_routing(&state.pool, location_id, true).await?; + let auto_connect_disabled = + Location::update_routing(&state.pool, location_id, true).await?; changed.push("route-all-traffic → on".to_string()); + if auto_connect_disabled { + changed.push("auto-connect → off".to_string()); + } } else if predefined_traffic { Location::update_routing(&state.pool, location_id, false).await?; changed.push("route-all-traffic → off".to_string()); diff --git a/src-tauri/client-cli/src/commands/location/tests.rs b/src-tauri/client-cli/src/commands/location/tests.rs index c3db3d1a1..71e0ec9bf 100644 --- a/src-tauri/client-cli/src/commands/location/tests.rs +++ b/src-tauri/client-cli/src/commands/location/tests.rs @@ -10,6 +10,7 @@ fn make_location(id: Id, instance_id: Id, name: &str, endpoint: &str, mfa: bool) mfa_steps: Json::default(), mfa_step_plan: Json::default(), client_mtu: None, + auto_connect: false, id, instance_id, network_id: 1, diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 0fc1c9555..dd1e6c681 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -953,6 +953,7 @@ mod tests { mfa_steps: Json::default(), mfa_step_plan: Json::default(), client_mtu: None, + auto_connect: false, id: 1, instance_id: 1, network_id: 1, diff --git a/src-tauri/client-cli/src/polling.rs b/src-tauri/client-cli/src/polling.rs index af790b1f8..e8f8a7da6 100644 --- a/src-tauri/client-cli/src/polling.rs +++ b/src-tauri/client-cli/src/polling.rs @@ -36,6 +36,16 @@ pub async fn poll_config(state: &State) { outcome.instance_name ); } + Ok(PollInstanceResult::Updated { + auto_connect_disabled, + .. + }) if !auto_connect_disabled.is_empty() => { + eprintln!( + "Instance {} configuration update turned off auto-connect for: {}", + outcome.instance_name, + auto_connect_disabled.join(", ") + ); + } Ok(PollInstanceResult::Updated { .. } | PollInstanceResult::Unchanged { .. }) => {} Err(Error::CoreNotEnterprise) => { debug!( diff --git a/src-tauri/client-cli/src/resolve.rs b/src-tauri/client-cli/src/resolve.rs index 61e854424..b164ec929 100644 --- a/src-tauri/client-cli/src/resolve.rs +++ b/src-tauri/client-cli/src/resolve.rs @@ -219,6 +219,7 @@ mod tests { mfa_steps: Json::default(), mfa_step_plan: Json::default(), client_mtu: None, + auto_connect: false, } } diff --git a/src-tauri/client-cli/src/tests_daemon.rs b/src-tauri/client-cli/src/tests_daemon.rs index e8620cc5f..1f4af107c 100644 --- a/src-tauri/client-cli/src/tests_daemon.rs +++ b/src-tauri/client-cli/src/tests_daemon.rs @@ -202,6 +202,7 @@ async fn test_active_state_lists_interfaces(pool: DbPool) { mfa_steps: Default::default(), mfa_step_plan: Default::default(), client_mtu: None, + auto_connect: false, } .save(&pool) .await diff --git a/src-tauri/core/src/database/models/connection.rs b/src-tauri/core/src/database/models/connection.rs index d2e59da85..96e8d6ae4 100644 --- a/src-tauri/core/src/database/models/connection.rs +++ b/src-tauri/core/src/database/models/connection.rs @@ -217,6 +217,7 @@ mod tests { mfa_steps: Default::default(), mfa_step_plan: Default::default(), client_mtu: None, + auto_connect: false, } .save(pool) .await diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs index 7fd8388c6..f727ccb7c 100644 --- a/src-tauri/core/src/database/models/location.rs +++ b/src-tauri/core/src/database/models/location.rs @@ -1,12 +1,14 @@ -use std::fmt; #[cfg(not(target_os = "macos"))] use std::str::FromStr; +use std::{collections::HashMap, fmt}; use defguard_wireguard_rs::net::IpAddrMask; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::{key::Key, peer::Peer, InterfaceConfiguration}; use serde::{Deserialize, Serialize}; -use sqlx::{prelude::Type, query, query_as, query_scalar, types::Json, SqliteExecutor}; +use sqlx::{ + prelude::Type, query, query_as, query_scalar, types::Json, SqliteConnection, SqliteExecutor, +}; #[cfg(not(target_os = "macos"))] use super::wireguard_keys::WireguardKeys; @@ -214,6 +216,8 @@ pub struct Location { /// application settings. #[serde(default)] pub client_mtu: Option, + #[serde(default)] + pub auto_connect: bool, } impl fmt::Display for Location { @@ -243,7 +247,7 @@ impl Location { location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ - mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" \ + mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect \ FROM location WHERE service_location_mode <= $1 \ ORDER BY name ASC", max_service_location_mode @@ -282,7 +286,7 @@ impl Location { location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ - mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" \ + mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect \ FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", name, max, @@ -301,8 +305,8 @@ impl Location { endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, \ keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, \ mfa_method = $13, posture_check_required = $14, mfa_steps = $15, \ - mfa_step_plan = $16, client_mtu = $17 \ - WHERE id = $18", + mfa_step_plan = $16, client_mtu = $17, auto_connect = $18 \ + WHERE id = $19", self.instance_id, self.name, self.address, @@ -320,6 +324,7 @@ impl Location { self.mfa_steps, self.mfa_step_plan, self.client_mtu, + self.auto_connect, self.id, ) .execute(executor) @@ -339,7 +344,7 @@ impl Location { location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ - mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" \ + mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect \ FROM location WHERE id = $1", location_id ) @@ -364,7 +369,7 @@ impl Location { location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ - mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" \ + mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect \ FROM location WHERE instance_id = $1 AND service_location_mode <= $2 \ ORDER BY name ASC", instance_id, @@ -385,7 +390,7 @@ impl Location { location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ - mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\" \ + mfa_step_plan \"mfa_step_plan: _\", client_mtu \"client_mtu: _\", auto_connect \ FROM location WHERE pubkey = $1", pubkey ) @@ -582,18 +587,42 @@ impl Location { Ok(()) } + pub async fn set_auto_connect( + pool: &DbPool, + location_id: Id, + auto_connect: bool, + ) -> Result<(), Error> { + // immediate lock so two concurrent requests can't both pass the all traffic check + let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?; + let mut location = Self::find_by_id(&mut *transaction, location_id) + .await? + .ok_or(Error::NotFound)?; + if auto_connect { + let context = AutoConnectContext::load(&mut transaction).await?; + if let Some(block) = context.block(&location) { + return Err(Error::InvalidInput(block.reason().into())); + } + } + location.auto_connect = auto_connect; + location.save(&mut *transaction).await?; + transaction.commit().await?; + Ok(()) + } + /// Persist the route-all-traffic flag for a location, rejecting the update if /// the owning instance's [`ClientTrafficPolicy`] forbids the requested value. + /// Returns whether auto-connect was turned off to avoid an all traffic collision. pub async fn update_routing( pool: &DbPool, location_id: Id, route_all_traffic: bool, - ) -> Result<(), Error> { - let mut location = Self::find_by_id(pool, location_id) + ) -> Result { + let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?; + let mut location = Self::find_by_id(&mut *transaction, location_id) .await? .ok_or(Error::NotFound)?; - let instance = Instance::find_by_id(pool, location.instance_id) + let instance = Instance::find_by_id(&mut *transaction, location.instance_id) .await? .ok_or(Error::NotFound)?; @@ -613,8 +642,21 @@ impl Location { } location.route_all_traffic = route_all_traffic; - location.save(pool).await?; - Ok(()) + let auto_connect_disabled = location.auto_connect + && AutoConnectContext::load(&mut transaction) + .await? + .block(&location) + .is_some(); + if auto_connect_disabled { + info!( + "Location {location} now routes all traffic while another all traffic location \ + is set to auto-connect, disabling its auto-connect" + ); + location.auto_connect = false; + } + location.save(&mut *transaction).await?; + transaction.commit().await?; + Ok(auto_connect_disabled) } /// Returns a filter value that can be used in SQL queries like `service_location_mode <= ?` @@ -639,8 +681,9 @@ impl Location { "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, \ dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, \ service_location_mode, mfa_method, posture_check_required, mfa_steps, \ - mfa_step_plan, client_mtu) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) \ + mfa_step_plan, client_mtu, auto_connect) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, \ + $18) \ RETURNING id \"id!\"", self.instance_id, self.name, @@ -659,6 +702,7 @@ impl Location { self.mfa_steps, self.mfa_step_plan, self.client_mtu, + self.auto_connect, ) .fetch_one(executor) .await?; @@ -682,6 +726,7 @@ impl Location { mfa_steps: self.mfa_steps, mfa_step_plan: self.mfa_step_plan, client_mtu: self.client_mtu, + auto_connect: self.auto_connect, }) } } @@ -697,6 +742,101 @@ impl Location { self.service_location_mode != ServiceLocationMode::Disabled && self.location_mfa_mode == LocationMfaMode::Disabled } + + /// Matches the routes `routed_networks` installs, so default routes in `allowed_ips` count. + /// Service locations ignore `route_all_traffic`, the daemon routes their `allowed_ips` as sent. + pub fn routes_all_traffic(&self, policy: &ClientTrafficPolicy) -> bool { + let forced = !self.is_service_location() + && match policy { + ClientTrafficPolicy::ForceAllTraffic => true, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::None => self.route_all_traffic, + }; + crate::routed_networks(&self.address, &self.allowed_ips, forced) + .iter() + .any(|network| network.cidr == 0) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoConnectBlock { + Mfa, + ServiceLocation, + AllTrafficTaken, +} + +impl AutoConnectBlock { + #[must_use] + pub fn reason(self) -> &'static str { + match self { + Self::Mfa => "Locations with MFA can't auto-connect.", + Self::ServiceLocation => "Service locations can't auto-connect.", + Self::AllTrafficTaken => { + "Another all traffic location is already set to auto-connect or managed by the \ + service." + } + } + } +} + +/// Snapshot of all instances' locations, since all traffic routes collide machine-wide. +pub struct AutoConnectContext { + policies: HashMap, + locations: Vec>, +} + +impl AutoConnectContext { + pub async fn load(connection: &mut SqliteConnection) -> Result { + let policies = Instance::all(&mut *connection) + .await? + .into_iter() + .map(|instance| (instance.id, instance.client_traffic_policy)) + .collect(); + let locations = Location::all(&mut *connection, true).await?; + Ok(Self { + policies, + locations, + }) + } + + #[must_use] + pub fn locations(&self) -> &[Location] { + &self.locations + } + + #[must_use] + pub fn routes_all_traffic(&self, location: &Location) -> bool { + self.policies + .get(&location.instance_id) + .is_some_and(|policy| location.routes_all_traffic(policy)) + } + + /// Checks `location` as given, so callers can pass unsaved changes. + #[must_use] + pub fn block(&self, location: &Location) -> Option { + if location.mfa_enabled() { + return Some(AutoConnectBlock::Mfa); + } + if location.service_location_mode != ServiceLocationMode::Disabled { + return Some(AutoConnectBlock::ServiceLocation); + } + let taken = self.routes_all_traffic(location) + && self.locations.iter().any(|other| { + other.id != location.id + && (other.auto_connect || other.is_service_location()) + && self.routes_all_traffic(other) + }); + taken.then_some(AutoConnectBlock::AllTrafficTaken) + } + + /// Locations set to auto-connect that can't, which must never stay saved. + pub fn invalid(&self) -> impl Iterator, AutoConnectBlock)> { + self.locations + .iter() + .filter(|location| location.auto_connect) + .filter_map(|location| self.block(location).map(|block| (location, block))) + } } impl From> for Location { @@ -720,6 +860,7 @@ impl From> for Location { mfa_steps: location.mfa_steps, mfa_step_plan: location.mfa_step_plan, client_mtu: location.client_mtu, + auto_connect: location.auto_connect, } } } @@ -765,7 +906,7 @@ mod tests { address: "10.0.0.2/24".into(), pubkey: "pk".into(), endpoint: "1.2.3.4:51820".into(), - allowed_ips: "0.0.0.0/0".into(), + allowed_ips: "10.0.0.0/24".into(), dns: None, route_all_traffic: false, keepalive_interval: 25, @@ -776,6 +917,7 @@ mod tests { mfa_steps: Default::default(), mfa_step_plan: Default::default(), client_mtu: None, + auto_connect: false, } } @@ -853,6 +995,366 @@ mod tests { assert_eq!(found.client_mtu, None); } + #[sqlx::test(migrations = "../migrations")] + async fn test_auto_connect_round_trip(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id).save(&pool).await.unwrap(); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(!found.auto_connect); + + location.auto_connect = true; + location.save(&pool).await.unwrap(); + let mut found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(found.auto_connect); + + found.name = "renamed".into(); + found.save(&pool).await.unwrap(); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(found.auto_connect); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let location = new_location(instance.id).save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, location.id, true) + .await + .unwrap(); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(found.auto_connect); + + Location::set_auto_connect(&pool, location.id, false) + .await + .unwrap(); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(!found.auto_connect); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_rejects_mfa_location(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.location_mfa_mode = LocationMfaMode::Internal; + let location = location.save(&pool).await.unwrap(); + + let result = Location::set_auto_connect(&pool, location.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(!found.auto_connect); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_allows_one_all_traffic_location(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut first = new_location(instance.id); + first.route_all_traffic = true; + let first = first.save(&pool).await.unwrap(); + let mut second = new_location(instance.id); + second.network_id = 2; + second.route_all_traffic = true; + let second = second.save(&pool).await.unwrap(); + let mut predefined = new_location(instance.id); + predefined.network_id = 3; + let predefined = predefined.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, first.id, true) + .await + .unwrap(); + let result = Location::set_auto_connect(&pool, second.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + Location::set_auto_connect(&pool, predefined.id, true) + .await + .unwrap(); + + Location::set_auto_connect(&pool, first.id, false) + .await + .unwrap(); + Location::set_auto_connect(&pool, second.id, true) + .await + .unwrap(); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_rejects_all_traffic_with_service_location(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut service = new_location(instance.id); + service.allowed_ips = "0.0.0.0/0".into(); + service.service_location_mode = ServiceLocationMode::AlwaysOn; + let service = service.save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.network_id = 2; + location.route_all_traffic = true; + let location = location.save(&pool).await.unwrap(); + + let result = Location::set_auto_connect(&pool, location.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + let result = Location::set_auto_connect(&pool, service.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_allows_all_traffic_beside_predefined_service_location( + pool: SqlitePool, + ) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut service = new_location(instance.id); + service.service_location_mode = ServiceLocationMode::AlwaysOn; + service.save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.network_id = 2; + location.route_all_traffic = true; + let location = location.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, location.id, true) + .await + .unwrap(); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_force_all_traffic_allows_one_location(pool: SqlitePool) { + let mut instance = new_instance(); + instance.client_traffic_policy = ClientTrafficPolicy::ForceAllTraffic; + let instance = instance.save(&pool).await.unwrap(); + let first = new_location(instance.id).save(&pool).await.unwrap(); + let mut second = new_location(instance.id); + second.network_id = 2; + let second = second.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, first.id, true) + .await + .unwrap(); + let result = Location::set_auto_connect(&pool, second.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_rejects_second_default_route_location(pool: SqlitePool) { + for policy in [ + ClientTrafficPolicy::None, + ClientTrafficPolicy::DisableAllTraffic, + ] { + let mut instance = new_instance(); + instance.name = format!("{policy:?}"); + instance.uuid = format!("uuid-{policy:?}"); + instance.client_traffic_policy = policy; + let instance = instance.save(&pool).await.unwrap(); + let mut first = new_location(instance.id); + first.allowed_ips = "0.0.0.0/0".into(); + let first = first.save(&pool).await.unwrap(); + let mut second = new_location(instance.id); + second.network_id = 2; + second.allowed_ips = "10.0.0.0/24, 0.0.0.0/0".into(); + let second = second.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, first.id, true) + .await + .unwrap(); + let result = Location::set_auto_connect(&pool, second.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + Location::set_auto_connect(&pool, first.id, false) + .await + .unwrap(); + } + } + + #[test] + fn test_routes_all_traffic_counts_default_route_in_allowed_ips() { + let location = |allowed_ips: &str| { + let mut location = new_location(1); + location.allowed_ips = allowed_ips.into(); + location + }; + let predefined = location("10.0.0.0/24"); + let ipv4_default = location("10.0.0.0/24, 0.0.0.0/0"); + let ipv6_default = location("::/0"); + + for policy in [ + ClientTrafficPolicy::None, + ClientTrafficPolicy::DisableAllTraffic, + ] { + assert!(!predefined.routes_all_traffic(&policy)); + assert!(ipv4_default.routes_all_traffic(&policy)); + assert!(ipv6_default.routes_all_traffic(&policy)); + } + assert!(predefined.routes_all_traffic(&ClientTrafficPolicy::ForceAllTraffic)); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_update_routing_disables_colliding_auto_connect(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut first = new_location(instance.id); + first.route_all_traffic = true; + first.auto_connect = true; + let first = first.save(&pool).await.unwrap(); + let mut second = new_location(instance.id); + second.network_id = 2; + second.auto_connect = true; + let second = second.save(&pool).await.unwrap(); + + let auto_connect_disabled = Location::update_routing(&pool, second.id, true) + .await + .unwrap(); + assert!(auto_connect_disabled); + + let found = |id| Location::find_by_id(&pool, id); + assert!(found(first.id).await.unwrap().unwrap().auto_connect); + let second = found(second.id).await.unwrap().unwrap(); + assert!(second.route_all_traffic); + assert!(!second.auto_connect); + assert_auto_connect_valid(&pool).await; + } + + async fn assert_auto_connect_valid(pool: &SqlitePool) { + let mut connection = pool.acquire().await.unwrap(); + let context = AutoConnectContext::load(&mut connection).await.unwrap(); + let invalid = context + .invalid() + .map(|(location, block)| (location.name.clone(), block)) + .collect::>(); + assert!(invalid.is_empty(), "invalid auto-connect: {invalid:?}"); + } + + async fn save_other_instance(pool: &SqlitePool) -> Instance { + let mut instance = new_instance(); + instance.name = "other".into(); + instance.uuid = "uuid-2".into(); + instance.save(pool).await.unwrap() + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_allows_one_all_traffic_location_across_instances( + pool: SqlitePool, + ) { + let instance = new_instance().save(&pool).await.unwrap(); + let other_instance = save_other_instance(&pool).await; + let mut first = new_location(instance.id); + first.route_all_traffic = true; + let first = first.save(&pool).await.unwrap(); + let mut second = new_location(other_instance.id); + second.route_all_traffic = true; + let second = second.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, first.id, true) + .await + .unwrap(); + let result = Location::set_auto_connect(&pool, second.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + assert_auto_connect_valid(&pool).await; + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_rejects_all_traffic_with_other_instance_service_location( + pool: SqlitePool, + ) { + let instance = new_instance().save(&pool).await.unwrap(); + let other_instance = save_other_instance(&pool).await; + let mut service = new_location(other_instance.id); + service.allowed_ips = "0.0.0.0/0".into(); + service.service_location_mode = ServiceLocationMode::AlwaysOn; + service.save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.route_all_traffic = true; + let location = location.save(&pool).await.unwrap(); + + let result = Location::set_auto_connect(&pool, location.id, true).await; + assert!(matches!(result, Err(Error::InvalidInput(_)))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_update_routing_disables_auto_connect_beside_all_traffic_service_location( + pool: SqlitePool, + ) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut service = new_location(instance.id); + service.allowed_ips = "::/0".into(); + service.service_location_mode = ServiceLocationMode::AlwaysOn; + service.save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.network_id = 2; + location.auto_connect = true; + let location = location.save(&pool).await.unwrap(); + + let auto_connect_disabled = Location::update_routing(&pool, location.id, true) + .await + .unwrap(); + + assert!(auto_connect_disabled); + assert_auto_connect_valid(&pool).await; + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_update_routing_keeps_auto_connect_without_collision(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.auto_connect = true; + let location = location.save(&pool).await.unwrap(); + + let auto_connect_disabled = Location::update_routing(&pool, location.id, true) + .await + .unwrap(); + + assert!(!auto_connect_disabled); + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert!(found.auto_connect); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_set_auto_connect_off_ignores_blocks(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.location_mfa_mode = LocationMfaMode::Internal; + location.auto_connect = true; + let location = location.save(&pool).await.unwrap(); + + Location::set_auto_connect(&pool, location.id, false) + .await + .unwrap(); + + assert_auto_connect_valid(&pool).await; + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_concurrent_all_traffic_requests_keep_one(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut ids = Vec::new(); + for network_id in 1..=2 { + let mut location = new_location(instance.id); + location.network_id = network_id; + location.route_all_traffic = true; + ids.push(location.save(&pool).await.unwrap().id); + } + + let (first, second) = tokio::join!( + Location::set_auto_connect(&pool, ids[0], true), + Location::set_auto_connect(&pool, ids[1], true), + ); + + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + assert_auto_connect_valid(&pool).await; + } + #[test] fn test_infer_mfa_method() { use LocationMfaMethod::{Biometric, Email, Oidc, Totp}; diff --git a/src-tauri/core/src/database/models/location_stats.rs b/src-tauri/core/src/database/models/location_stats.rs index 536e76837..2cbb5206b 100644 --- a/src-tauri/core/src/database/models/location_stats.rs +++ b/src-tauri/core/src/database/models/location_stats.rs @@ -291,6 +291,7 @@ mod tests { mfa_steps: Default::default(), mfa_step_plan: Default::default(), client_mtu: None, + auto_connect: false, } .save(pool) .await diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 02e1f91bc..547f3dc9f 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -243,6 +243,7 @@ pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location::from(location); new_location.route_all_traffic = false; + new_location.auto_connect = false; new_location.mfa_method = infer_mfa_method(new_location.location_mfa_mode, None); new_location }) @@ -48,12 +49,17 @@ pub async fn locations_changed( Ok(db_locations != core_locations) } -/// Applies a fetched configuration to the local database. Returns whether the location set changed. +#[derive(Debug, Default)] +pub struct InstanceUpdate { + pub locations_changed: bool, + pub auto_connect_disabled: Vec, +} + pub async fn do_update_instance( transaction: &mut Transaction<'_, Sqlite>, instance: &mut Instance, response: DeviceConfigResponse, -) -> Result { +) -> Result { debug!("Updating instance {instance}"); let locations_changed_val = locations_changed(transaction, instance, &response).await?; let instance_info = response @@ -155,8 +161,57 @@ pub async fn do_update_instance( } else { info!("Locations for instance {instance} didn't change. Not updating them."); } + let auto_connect_disabled = disable_invalid_auto_connect(transaction).await?; + + Ok(InstanceUpdate { + locations_changed: locations_changed_val, + auto_connect_disabled, + }) +} + +/// Checks every instance, since all traffic routes collide machine-wide. +/// Colliding all traffic locations all lose auto-connect until the user picks one again. +async fn disable_invalid_auto_connect( + transaction: &mut Transaction<'_, Sqlite>, +) -> Result, Error> { + let context = AutoConnectContext::load(transaction).await?; + + let all_traffic_service_locations = context + .locations() + .iter() + .filter(|location| location.is_service_location() && context.routes_all_traffic(location)) + .map(ToString::to_string) + .collect::>(); + if all_traffic_service_locations.len() > 1 { + warn!( + "Multiple service locations route all traffic: {}", + all_traffic_service_locations.join(", ") + ); + } + + let mut disabled = Vec::new(); + for (location, block) in context.invalid() { + match block { + AutoConnectBlock::Mfa => { + info!("Location {location} now requires MFA, disabling its auto-connect"); + } + AutoConnectBlock::ServiceLocation => { + info!("Location {location} is now a service location, disabling its auto-connect"); + } + AutoConnectBlock::AllTrafficTaken => { + warn!( + "Location {location} collides with another all traffic location, disabling \ + its auto-connect" + ); + } + } + let mut location = location.clone(); + location.auto_connect = false; + location.save(transaction.as_mut()).await?; + disabled.push(location.name); + } - Ok(locations_changed_val) + Ok(disabled) } /// Synchronizes the daemon's persisted service-location state from the current database state. diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index d4dcaf981..080feaa21 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -26,6 +26,7 @@ use sqlx::{Sqlite, Transaction}; use crate::commands::{ disable_enterprise_features, do_update_instance, sync_service_locations_best_effort, + InstanceUpdate, }; static POLLING_ENDPOINT: &str = "/api/v1/poll"; @@ -47,6 +48,7 @@ pub enum PollInstanceResult { }, Updated { locations_changed: bool, + auto_connect_disabled: Vec, version_mismatch: Option, }, ChangedWhileActive { @@ -261,8 +263,10 @@ async fn apply_fetched_config( instance.name, instance.id, ); instance.mfa_contract = mfa_contract; - let locations_changed = - do_update_instance(transaction, instance, device_config.clone()).await?; + let InstanceUpdate { + locations_changed, + auto_connect_disabled, + } = do_update_instance(transaction, instance, device_config.clone()).await?; info!( "Updated instance {}({}) configuration based on core's response", instance.name, instance.id @@ -270,6 +274,7 @@ async fn apply_fetched_config( Ok(PollInstanceResult::Updated { locations_changed, + auto_connect_disabled, version_mismatch, }) } diff --git a/src-tauri/enterprise/config-sync/src/tests.rs b/src-tauri/enterprise/config-sync/src/tests.rs index 230faf055..a29115d50 100644 --- a/src-tauri/enterprise/config-sync/src/tests.rs +++ b/src-tauri/enterprise/config-sync/src/tests.rs @@ -9,14 +9,17 @@ use std::{ use defguard_client_core::{ database::models::{ instance::{ClientTrafficPolicy, MfaCapabilities, OpenIdProviderKind}, - location::{Location, LocationMfaMethod, LocationMfaMode, ServiceLocationMode}, + location::{ + AutoConnectContext, Location, LocationMfaMethod, LocationMfaMode, ServiceLocationMode, + }, NoId, }, mfa_contract::MfaContract, }; use defguard_client_proto::defguard::client_types::{ - DeviceConfig, DeviceConfigResponse, InstanceInfo, MfaCapabilities as ProtoMfaCapabilities, - MfaMethod, MfaUserState, + DeviceConfig, DeviceConfigResponse, InstanceInfo, LocationMfaMode as ProtoLocationMfaMode, + MfaCapabilities as ProtoMfaCapabilities, MfaMethod, MfaUserState, + ServiceLocationMode as ProtoServiceLocationMode, }; use sqlx::SqlitePool; @@ -152,7 +155,7 @@ fn device_config(network_id: Id, name: &str, endpoint: &str) -> DeviceConfig { endpoint: endpoint.into(), assigned_ip: "10.6.0.2".into(), pubkey: format!("pk-{network_id}"), - allowed_ips: "0.0.0.0/0".into(), + allowed_ips: "10.0.0.0/24".into(), keepalive_interval: 25, ..Default::default() } @@ -227,7 +230,7 @@ async fn seed_location( address: "10.6.0.2".into(), pubkey: format!("pk-{network_id}"), endpoint: endpoint.into(), - allowed_ips: "0.0.0.0/0".into(), + allowed_ips: "10.0.0.0/24".into(), dns: None, route_all_traffic: false, keepalive_interval: 25, @@ -238,6 +241,7 @@ async fn seed_location( mfa_steps: Default::default(), mfa_step_plan: Default::default(), client_mtu: None, + auto_connect: false, } .save(pool) .await @@ -347,6 +351,305 @@ async fn test_config_changed_true_when_location_changes(pool: SqlitePool) { assert!(changed); } +#[sqlx::test(migrations = "../../migrations")] +async fn test_config_changed_ignores_auto_connect(pool: SqlitePool) { + let instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + let mut location = seed_location(&pool, instance.id, 1, "office", "1.2.3.4:51820").await; + location.auto_connect = true; + location.save(&pool).await.unwrap(); + let response = device_config_response(&instance, device_config(1, "office", "1.2.3.4:51820")); + + let mut transaction = pool.begin().await.unwrap(); + let changed = config_changed(&mut transaction, &instance, &response) + .await + .unwrap(); + + assert!(!changed); +} + +async fn seed_auto_connect_location( + pool: &SqlitePool, + instance_id: Id, + network_id: Id, + name: &str, + route_all_traffic: bool, +) { + let mut location = seed_location(pool, instance_id, network_id, name, "1.2.3.4:51820").await; + location.route_all_traffic = route_all_traffic; + location.auto_connect = true; + location.save(pool).await.unwrap(); +} + +fn service_config(network_id: Id, name: &str, allowed_ips: &str) -> DeviceConfig { + let mut config = device_config(network_id, name, "1.2.3.4:51820"); + config.allowed_ips = allowed_ips.into(); + config.service_location_mode = Some(ProtoServiceLocationMode::Alwayson as i32); + config +} + +async fn update_and_read_auto_connect( + pool: &SqlitePool, + instance: &mut Instance, + response: DeviceConfigResponse, +) -> (Vec<(String, bool)>, Vec) { + let mut transaction = pool.begin().await.unwrap(); + let mut disabled = do_update_instance(&mut transaction, instance, response) + .await + .unwrap() + .auto_connect_disabled; + transaction.commit().await.unwrap(); + disabled.sort(); + let mut connection = pool.acquire().await.unwrap(); + let context = AutoConnectContext::load(&mut connection).await.unwrap(); + assert_eq!(context.invalid().count(), 0); + let auto_connect = Location::find_by_instance_id(pool, instance.id, true) + .await + .unwrap() + .into_iter() + .map(|location| (location.name, location.auto_connect)) + .collect(); + (auto_connect, disabled) +} + +fn auto_connect_of(names: &[(&str, bool)]) -> Vec<(String, bool)> { + names + .iter() + .map(|(name, auto_connect)| ((*name).to_string(), *auto_connect)) + .collect() +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_disables_colliding_all_traffic_auto_connect(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", true).await; + seed_auto_connect_location(&pool, instance.id, 2, "second", true).await; + seed_auto_connect_location(&pool, instance.id, 3, "predefined", false).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response.configs.extend([ + device_config(2, "second", "1.2.3.4:51820"), + device_config(3, "predefined", "1.2.3.4:51820"), + ]); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", false), ("predefined", true), ("second", false)]) + ); + assert_eq!(disabled, ["first", "second"]); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_disables_all_traffic_auto_connect_across_instances(pool: SqlitePool) { + let other = seed_instance(&pool, "other", "https://other-proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, other.id, 1, "other-all", true).await; + seed_auto_connect_location(&pool, other.id, 2, "other-predefined", false).await; + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + let response = device_config_response(&instance, service_config(1, "service", "0.0.0.0/0")); + + let (_, disabled) = update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!(disabled, ["other-all"]); + let other_auto_connect = Location::find_by_instance_id(&pool, other.id, true) + .await + .unwrap() + .into_iter() + .map(|location| (location.name, location.auto_connect)) + .collect::>(); + assert_eq!( + other_auto_connect, + auto_connect_of(&[("other-all", false), ("other-predefined", true)]) + ); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_disables_colliding_default_route_auto_connect(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", false).await; + seed_auto_connect_location(&pool, instance.id, 2, "second", false).await; + let mut first = device_config(1, "first", "1.2.3.4:51820"); + first.allowed_ips = "0.0.0.0/0".into(); + let mut second = device_config(2, "second", "1.2.3.4:51820"); + second.allowed_ips = "10.0.0.0/24, 0.0.0.0/0".into(); + let mut response = device_config_response(&instance, first); + response.configs.push(second); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", false), ("second", false)]) + ); + assert_eq!(disabled, ["first", "second"]); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_keeps_single_all_traffic_auto_connect(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", true).await; + seed_auto_connect_location(&pool, instance.id, 2, "predefined", false).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response + .configs + .push(device_config(2, "predefined", "1.2.3.4:51820")); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", true), ("predefined", true)]) + ); + assert!(disabled.is_empty()); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_all_traffic_service_location_disables_auto_connect(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", true).await; + seed_auto_connect_location(&pool, instance.id, 2, "predefined", false).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response.configs.extend([ + device_config(2, "predefined", "1.2.3.4:51820"), + service_config(3, "service", "10.0.0.0/24, 0.0.0.0/0"), + ]); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", false), ("predefined", true), ("service", false)]) + ); + assert_eq!(disabled, ["first"]); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_keeps_auto_connect_beside_predefined_service_location( + pool: SqlitePool, +) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", true).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response + .configs + .push(service_config(2, "service", "10.0.0.0/24")); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", true), ("service", false)]) + ); + assert!(disabled.is_empty()); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_keeps_multiple_all_traffic_service_locations(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", true).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response.configs.extend([ + service_config(2, "service-a", "0.0.0.0/0"), + service_config(3, "service-b", "::/0"), + ]); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", false), ("service-a", false), ("service-b", false)]) + ); + assert_eq!(disabled, ["first"]); + let service_locations = Location::find_by_instance_id(&pool, instance.id, true) + .await + .unwrap() + .into_iter() + .filter(Location::is_service_location) + .count(); + assert_eq!(service_locations, 2); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_disables_auto_connect_for_new_service_location(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "office", false).await; + let response = device_config_response(&instance, service_config(1, "office", "10.0.0.0/24")); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!(auto_connect, auto_connect_of(&[("office", false)])); + assert_eq!(disabled, ["office"]); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn test_update_instance_force_all_traffic_disables_colliding_auto_connect(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_auto_connect_location(&pool, instance.id, 1, "first", false).await; + seed_auto_connect_location(&pool, instance.id, 2, "second", false).await; + let mut response = + device_config_response(&instance, device_config(1, "first", "1.2.3.4:51820")); + response + .configs + .push(device_config(2, "second", "1.2.3.4:51820")); + response.instance.as_mut().unwrap().client_traffic_policy = + Some(ClientTrafficPolicy::ForceAllTraffic as i32); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!( + auto_connect, + auto_connect_of(&[("first", false), ("second", false)]) + ); + assert_eq!(disabled, ["first", "second"]); +} + +#[sqlx::test(migrations = "../../migrations")] +#[allow( + deprecated, + reason = "location_mfa_mode is the only proto field that sets the mode" +)] +async fn test_update_instance_disables_auto_connect_for_mfa_locations(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + for (network_id, name) in [(1, "internal"), (2, "external"), (3, "plain")] { + let mut location = + seed_location(&pool, instance.id, network_id, name, "1.2.3.4:51820").await; + location.auto_connect = true; + location.save(&pool).await.unwrap(); + } + let mut internal = device_config(1, "internal", "1.2.3.4:51820"); + internal.location_mfa_mode = Some(ProtoLocationMfaMode::Internal as i32); + let mut external = device_config(2, "external", "1.2.3.4:51820"); + external.location_mfa_mode = Some(ProtoLocationMfaMode::External as i32); + let plain = device_config(3, "plain", "5.6.7.8:51820"); + let mut response = device_config_response(&instance, internal); + response.configs.extend([external, plain]); + + let (auto_connect, disabled) = + update_and_read_auto_connect(&pool, &mut instance, response).await; + + assert_eq!(disabled, ["external", "internal"]); + assert_eq!( + auto_connect, + [ + ("external".to_string(), false), + ("internal".to_string(), false), + ("plain".to_string(), true), + ] + ); +} + #[sqlx::test(migrations = "../../migrations")] async fn test_poll_persists_mfa_contract_when_config_is_unchanged(pool: SqlitePool) { let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; diff --git a/src-tauri/justfile b/src-tauri/justfile index 1b2da43b2..9c6a8da1c 100644 --- a/src-tauri/justfile +++ b/src-tauri/justfile @@ -1,8 +1,14 @@ +export SQLX_OFFLINE := "true" + # format Rust project format: - cargo +nightly --locked fmt --all # use nightly toolchain for better import handling + cargo --locked fmt --all # lint Rust project lint: cargo clippy --workspace --all-targets --all-features -- -D warnings +# test Rust project with nextest +test *args: + cargo nextest run --workspace --no-fail-fast {{args}} + diff --git a/src-tauri/migrations/20261029115253_add_location_auto_connect.sql b/src-tauri/migrations/20261029115253_add_location_auto_connect.sql new file mode 100644 index 000000000..59da68e6e --- /dev/null +++ b/src-tauri/migrations/20261029115253_add_location_auto_connect.sql @@ -0,0 +1 @@ +ALTER TABLE location ADD COLUMN auto_connect BOOLEAN NOT NULL DEFAULT false; diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index ff27fd248..7ea998563 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -60,6 +60,7 @@ commands.allow = [ "take_pending_add_instance", "get_platform_header", "set_location_mfa_step_plan", + "set_location_auto_connect", "open_tray_window", "open_full_view_window", "swap_to_tray", diff --git a/src-tauri/src/auto_connect.rs b/src-tauri/src/auto_connect.rs new file mode 100644 index 000000000..540b29249 --- /dev/null +++ b/src-tauri/src/auto_connect.rs @@ -0,0 +1,150 @@ +//! Connects locations marked for auto-connect once the app has started after user login. + +use std::time::Duration; + +use defguard_client_core::connection::active_connections::find_connection; +use tauri::AppHandle; +use tokio::time::sleep; + +use crate::{ + commands::{connect_location, load_auto_connect_context, ConnectError}, + database::{ + models::{location::Location, Id}, + DB_POOL, + }, + events::notify_auto_connect_failed, + ConnectionType, +}; + +// network and DNS may still be coming up right after login +const RETRY_DELAYS: [Duration; 4] = [ + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(15), + Duration::from_secs(30), +]; + +enum Attempt { + Done, + Retry, + Failed, +} + +fn is_retryable(error: &ConnectError) -> bool { + !matches!( + error, + ConnectError::RouteConflict { .. } | ConnectError::PostureCheckFailed { .. } + ) +} + +pub(crate) async fn connect_auto_connect_locations(app_handle: &AppHandle) { + let mut pending = match Location::all(&*DB_POOL, false).await { + Ok(locations) => locations + .into_iter() + .filter(|location| location.auto_connect) + .map(|location| (location.id, location.name)) + .collect::>(), + Err(err) => { + error!("Failed to load locations marked for auto-connect: {err}"); + return; + } + }; + let mut failed = Vec::new(); + let mut delays = RETRY_DELAYS.iter(); + + while !pending.is_empty() { + let mut retry = Vec::new(); + for (location_id, name) in pending { + match try_auto_connect(location_id, app_handle).await { + Attempt::Done => {} + Attempt::Retry => retry.push((location_id, name)), + Attempt::Failed => failed.push(name), + } + } + if retry.is_empty() { + break; + } + let Some(delay) = delays.next() else { + failed.extend(retry.into_iter().map(|(_, name)| name)); + break; + }; + debug!( + "Retrying auto-connect for {} locations in {delay:?}", + retry.len() + ); + sleep(*delay).await; + pending = retry; + } + + notify_auto_connect_failed(app_handle, &failed); +} + +/// Re-reads the location, the user may have changed it or connected it between retries. +async fn try_auto_connect(location_id: Id, app_handle: &AppHandle) -> Attempt { + let location = match Location::find_by_id(&*DB_POOL, location_id).await { + Ok(Some(location)) if location.auto_connect => location, + Ok(_) => return Attempt::Done, + Err(err) => { + error!("Failed to load location {location_id} for auto-connect: {err}"); + return Attempt::Retry; + } + }; + if find_connection(location.id, ConnectionType::Location) + .await + .is_some() + { + debug!("Location {location} is already connected, skipping auto-connect."); + return Attempt::Done; + } + match load_auto_connect_context().await { + Ok(context) => { + if let Some(block) = context.block(&location) { + warn!( + "Skipping auto-connect for location {location}: {}", + block.reason() + ); + return Attempt::Done; + } + } + Err(err) => { + error!("Failed to check auto-connect for location {location}: {err}"); + return Attempt::Retry; + } + } + + info!("Auto-connecting location {location}."); + match connect_location(location.clone(), app_handle).await { + Ok(()) => Attempt::Done, + Err(err) if is_retryable(&err) => { + warn!("Auto-connect for location {location} failed, will retry: {err}"); + Attempt::Retry + } + Err(err) => { + error!("Failed to auto-connect location {location}: {err}"); + Attempt::Failed + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_retryable() { + let message = String::new(); + assert!(is_retryable(&ConnectError::ServiceUnavailable { + message: message.clone() + })); + assert!(is_retryable(&ConnectError::Other { + message: message.clone() + })); + assert!(!is_retryable(&ConnectError::PostureCheckFailed { + message: message.clone() + })); + assert!(!is_retryable(&ConnectError::RouteConflict { + message, + conflicts: Vec::new(), + })); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 76b932aeb..bee13db5b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -46,7 +46,10 @@ use crate::{ models::{ connection::{ActiveConnection, Connection, ConnectionInfo}, instance::{mfa_contract_from_instance_info, Instance, InstanceInfo}, - location::{Location, LocationMfaMethod, LocationMfaMode, LocationMfaStep}, + location::{ + AutoConnectBlock, AutoConnectContext, Location, LocationMfaMethod, LocationMfaMode, + LocationMfaStep, + }, location_stats::LocationStats, tunnel::{Tunnel, TunnelConnection, TunnelConnectionInfo, TunnelStats}, wireguard_keys::WireguardKeys, @@ -55,7 +58,10 @@ use crate::{ DB_POOL, }, error::Error, - events::{AddInstancePayload, EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, + events::{ + notify_auto_connect_disabled, AddInstancePayload, EventKey, TunnelsDisabledPayload, + TunnelsEnabledPayload, + }, fido2_window::{platform_context, WindowLevelGuard}, into_location, log_watcher::{ @@ -64,6 +70,7 @@ use crate::{ }, periodic::config::{ do_update_instance, poll_instance_with_events, sync_service_locations_best_effort, + InstanceUpdate, }, proxy::construct_platform_header, tauri_err_to_app_err, @@ -256,6 +263,31 @@ async fn connect_location_with_psk( Ok(()) } +pub(crate) async fn connect_location( + location: Location, + handle: &AppHandle, +) -> Result<(), ConnectError> { + if location.is_service_location() { + error!( + "Refusing to connect location {location} from the app: it is a service \ + location, managed by the background service" + ); + return Err(Error::InvalidInput(format!( + "Location \"{}\" is a service location and is managed by the defguard service", + location.name + )) + .into()); + } + // MFA flows bring the tunnel up themselves, only posture-only locations resolve a PSK here + let preshared_key = if location.posture_check_required { + authorize_posture_session(&location).await? + } else { + None + }; + connect_location_with_psk(location, preshared_key, handle).await?; + Ok(()) +} + /// Open new WireGuard connection. #[tauri::command(async)] pub async fn connect( @@ -270,28 +302,7 @@ pub async fn connect( "Identified location with ID {location_id} as \"{}\", handling connection.", location.name ); - - // Avoid connecting a service location - they should be managed by the background service. - if location.is_service_location() { - error!( - "Refusing to connect location {location} from the app: it is a service \ - location, managed by the background service" - ); - return Err(Error::InvalidInput(format!( - "Location \"{}\" is a service location and is managed by the defguard service", - location.name - )) - .into()); - } - // Connect-time MFA brings the tunnel up itself (keeping the preshared - // key backend-side), so the only preshared key resolved here is for - // posture-only locations. - let preshared_key = if location.posture_check_required { - authorize_posture_session(&location).await? - } else { - None - }; - connect_location_with_psk(location, preshared_key, &handle).await?; + connect_location(location, &handle).await?; } else { error!( "Location with ID {location_id} not found in the database, aborting connection \ @@ -546,9 +557,10 @@ async fn refresh_instance_config(instance_id: Id, handle: &AppHandle) -> Result< error!("Instance {instance_id} not found, skipping config update check"); return Err(Error::NotFound); }; - let instance_changed = + let (instance_changed, auto_connect_disabled) = poll_instance_with_events(&mut transaction, &mut instance, handle).await?; transaction.commit().await?; + notify_auto_connect_disabled(handle, &auto_connect_disabled); sync_service_locations_best_effort(&DB_POOL, &instance).await; if instance_changed { @@ -780,6 +792,9 @@ pub struct LocationInfo { pub mfa_method: Option, pub mfa_steps: Vec, pub mfa_step_plan: Vec, + pub auto_connect: bool, + pub auto_connect_blocked: Option, + pub routes_all_traffic: bool, } impl LocationInfo { @@ -799,13 +814,26 @@ impl fmt::Display for LocationInfo { } } -/// `connected_location_ids` is read once by the caller, so a listing does not take the -/// connections lock per location. +pub(crate) async fn load_auto_connect_context() -> Result { + let mut connection = DB_POOL.acquire().await?; + AutoConnectContext::load(&mut connection).await +} + +/// `connected_location_ids` and `auto_connect_context` are read once by the caller, so a listing +/// does not query them per location. pub(crate) fn build_location_info( location: Location, connected_location_ids: &[Id], + auto_connect_context: &AutoConnectContext, ) -> LocationInfo { + let auto_connect_blocked = auto_connect_context.block(&location); + if location.auto_connect && auto_connect_blocked.is_some() { + warn!("Location {location} is set to auto-connect while it can't, showing it as off"); + } LocationInfo { + auto_connect: location.auto_connect && auto_connect_blocked.is_none(), + auto_connect_blocked, + routes_all_traffic: auto_connect_context.routes_all_traffic(&location), id: location.id, instance_id: location.instance_id, name: location.name, @@ -843,9 +871,10 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> locations.len() ); let active_locations_ids = get_connection_id_by_type(ConnectionType::Location).await; + let auto_connect_context = load_auto_connect_context().await?; let location_info = locations .into_iter() - .map(|location| build_location_info(location, &active_locations_ids)) + .map(|location| build_location_info(location, &active_locations_ids, &auto_connect_context)) .collect::>(); trace!( "Returning information about {} locations for instance {instance}", @@ -923,9 +952,12 @@ pub async fn update_instance( Error::ResourceNotFound("instance info in device config response".to_string()) })?; let mut transaction = DB_POOL.begin().await?; - let locations_changed = - do_update_instance(&mut transaction, &mut instance, response).await?; + let InstanceUpdate { + locations_changed, + auto_connect_disabled, + } = do_update_instance(&mut transaction, &mut instance, response).await?; transaction.commit().await?; + notify_auto_connect_disabled(&app_handle, &auto_connect_disabled); sync_service_locations_best_effort(&DB_POOL, &instance).await; @@ -1074,18 +1106,19 @@ pub async fn update_location_routing( route_all_traffic: bool, connection_type: ConnectionType, handle: AppHandle, -) -> Result<(), Error> { +) -> Result { debug!("Updating location routing {location_id} with {connection_type}"); let name = get_tunnel_or_location_name(location_id, connection_type).await; match connection_type { ConnectionType::Location => { - Location::update_routing(&DB_POOL, location_id, route_all_traffic).await?; + let auto_connect_disabled = + Location::update_routing(&DB_POOL, location_id, route_all_traffic).await?; debug!("Location routing updated for location {name}(ID: {location_id})"); handle .emit(EventKey::LocationUpdate.into(), ()) .map_err(tauri_err_to_app_err)?; - Ok(()) + Ok(auto_connect_disabled) } ConnectionType::Tunnel => { if let Some(mut tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? { @@ -1095,7 +1128,7 @@ pub async fn update_location_routing( handle .emit(EventKey::LocationUpdate.into(), ()) .map_err(tauri_err_to_app_err)?; - Ok(()) + Ok(false) } else { error!("Couldn't update tunnel routing: tunnel with id {location_id} not found."); Err(Error::NotFound) @@ -1119,6 +1152,21 @@ pub async fn set_location_mfa_step_plan( Ok(()) } +#[tauri::command(async)] +pub async fn set_location_auto_connect( + location_id: Id, + auto_connect: bool, + handle: AppHandle, +) -> Result<(), Error> { + debug!("Received command to set auto-connect {auto_connect} for location {location_id}"); + Location::set_auto_connect(&DB_POOL, location_id, auto_connect).await?; + debug!("Auto-connect updated for location (ID: {location_id})"); + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + Ok(()) +} + #[cfg(target_os = "macos")] #[tauri::command(async)] pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), Error> { diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 023b56f33..6fc176afb 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -111,6 +111,53 @@ impl TunnelsDisabledPayload { } } +const MAX_LISTED_LOCATIONS: usize = 3; + +fn list_locations(locations: &[String]) -> String { + let listed = locations[..locations.len().min(MAX_LISTED_LOCATIONS)].join(", "); + match locations.len().saturating_sub(MAX_LISTED_LOCATIONS) { + 0 => listed, + hidden => format!("{listed} and {hidden} more"), + } +} + +fn notify_locations(app_handle: &AppHandle, locations: &[String], title: &str, body: &str) { + if locations.is_empty() { + return; + } + let title = match locations.len() { + 1 => title.to_string(), + count => format!("{title} for {count} locations"), + }; + if let Err(err) = app_handle + .notification() + .builder() + .title(&title) + .body(format!("{} {body}", list_locations(locations))) + .show() + { + warn!("Notification \"{title}\" not shown. Reason: {err}"); + } +} + +pub(crate) fn notify_auto_connect_disabled(app_handle: &AppHandle, locations: &[String]) { + notify_locations( + app_handle, + locations, + "Auto-connect turned off", + "won't connect automatically at startup after a configuration update. Open Settings > Startup & Auto-connect for details.", + ); +} + +pub(crate) fn notify_auto_connect_failed(app_handle: &AppHandle, locations: &[String]) { + notify_locations( + app_handle, + locations, + "Auto-connect failed", + "couldn't connect automatically at startup. Connect manually or check the logs.", + ); +} + #[derive(Clone, Serialize)] pub struct TunnelsEnabledPayload; diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 695fd0f91..f8623ff77 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -26,6 +26,7 @@ use crate::utils::sync_connections; use crate::{ app_config::AppConfig, appstate::AppState, + auto_connect::connect_auto_connect_locations, autostart, commands::*, database::{ @@ -158,6 +159,11 @@ async fn startup(app_handle: &AppHandle) { Err(err) => error!("Failed to configure system tray: {err}"), } debug!("Tray menu has been re-generated successfully."); + + let auto_connect_handle = app_handle.clone(); + async_runtime::spawn(async move { + connect_auto_connect_locations(&auto_connect_handle).await; + }); } pub fn run_app() { @@ -198,6 +204,7 @@ pub fn run_app() { get_platform_header, get_posture_data, set_location_mfa_step_plan, + set_location_auto_connect, open_tray_window, open_full_view_window, swap_to_tray, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9adb9c8a..ccf52c91c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ #[cfg(target_os = "macos")] pub mod apple; pub mod appstate; +pub mod auto_connect; pub mod autostart; pub mod commands; pub mod events; diff --git a/src-tauri/src/log_watcher/log_file_reader.rs b/src-tauri/src/log_watcher/log_file_reader.rs index b49a55fdc..ff783a74a 100644 --- a/src-tauri/src/log_watcher/log_file_reader.rs +++ b/src-tauri/src/log_watcher/log_file_reader.rs @@ -1,57 +1,29 @@ //! Line reader that follows a log file by path and reopens it after rotation. -#[cfg(unix)] -use std::os::unix::fs::MetadataExt; -#[cfg(not(unix))] -use std::time::SystemTime; use std::{ - fs::{metadata, File, Metadata}, + fs::File, io::{self, BufRead, BufReader, Seek, SeekFrom}, path::{Path, PathBuf}, }; -#[derive(PartialEq, Eq)] -struct FileId { - #[cfg(unix)] - dev: u64, - #[cfg(unix)] - ino: u64, - #[cfg(not(unix))] - created: Option, -} - -impl FileId { - fn new(meta: &Metadata) -> Self { - #[cfg(unix)] - { - Self { - dev: meta.dev(), - ino: meta.ino(), - } - } - #[cfg(not(unix))] - { - Self { - created: meta.created().ok(), - } - } - } -} +use same_file::Handle; pub(crate) struct LogFileReader { path: PathBuf, reader: BufReader, - id: FileId, + // Windows keeps a renamed file's creation time for a new file with the same name, + // so identity must come from the volume and file index, not timestamps. + handle: Handle, } impl LogFileReader { pub(crate) fn open(path: &Path) -> io::Result { let file = File::open(path)?; - let id = FileId::new(&file.metadata()?); + let handle = Handle::from_file(file.try_clone()?)?; Ok(Self { path: path.to_path_buf(), reader: BufReader::new(file), - id, + handle, }) } @@ -66,18 +38,18 @@ impl LogFileReader { } fn reopen_if_rotated(&mut self) -> io::Result { - let meta = match metadata(&self.path) { - Ok(meta) => meta, - // Mid-rotation; keep the old file for now. + let current = match Handle::from_path(&self.path) { + Ok(handle) => handle, + // mid-rotation, keep reading the old file until the new one appears Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), Err(err) => return Err(err), }; let position = self.reader.stream_position()?; - if FileId::new(&meta) != self.id { + if current != self.handle { debug!("Log file {} was rotated, reopening", self.path.display()); *self = Self::open(&self.path)?; Ok(true) - } else if meta.len() < position { + } else if current.as_file().metadata()?.len() < position { debug!("Log file {} was truncated, rewinding", self.path.display()); self.reader.seek(SeekFrom::Start(0))?; Ok(true) diff --git a/src-tauri/src/periodic/config.rs b/src-tauri/src/periodic/config.rs index 5e136d039..e4e5f7c22 100644 --- a/src-tauri/src/periodic/config.rs +++ b/src-tauri/src/periodic/config.rs @@ -6,7 +6,7 @@ use std::{ pub use defguard_client_config_sync::commands::{ disable_enterprise_features, do_update_instance, locations_changed, sync_service_locations, - sync_service_locations_best_effort, + sync_service_locations_best_effort, InstanceUpdate, }; use defguard_client_config_sync::{ poll_instance, poll_instances, PollInstanceResult, VersionMismatchPayload, @@ -27,7 +27,9 @@ use tauri::{AppHandle, Emitter}; use tokio::time::sleep; use crate::{ - commands::disconnect_all_tunnels, events::TunnelsEnabledPayload, tray::reload_tray_menu, + commands::disconnect_all_tunnels, + events::{notify_auto_connect_disabled, TunnelsEnabledPayload}, + tray::reload_tray_menu, }; const INTERVAL_SECONDS: Duration = Duration::from_secs(30); @@ -77,6 +79,7 @@ pub async fn poll_config(handle: AppHandle) { let mut config_retrieved = 0; let mut tray_stale = false; + let mut auto_connect_disabled = Vec::new(); for outcome in outcomes { let instance_name = outcome.instance_name; let instance_id = outcome.instance_id; @@ -84,6 +87,7 @@ pub async fn poll_config(handle: AppHandle) { Ok(result) => { config_retrieved += 1; tray_stale |= instance_changed(&result); + auto_connect_disabled.extend_from_slice(disabled_auto_connect(&result)); emit_version_mismatch(&handle, instance_id, version_mismatch(&result)); emit_poll_result_events(&handle, instance_id, &instance_name, result); debug!( @@ -114,6 +118,7 @@ pub async fn poll_config(handle: AppHandle) { if tray_stale { reload_tray_menu(&handle).await; } + notify_auto_connect_disabled(&handle, &auto_connect_disabled); let currently_disabled = Instance::tunnels_disabled(&*DB_POOL).await.unwrap_or(false); @@ -147,23 +152,32 @@ pub async fn poll_config(handle: AppHandle) { } } -/// Retrieves configuration for a given [`Instance`]. -/// Updates the instance if there aren't any active connections, otherwise emits -/// a ConfigChanged event so the frontend can prompt the user to reconnect. -/// Returns whether the instance itself was written. +/// Applies the polled config, or emits ConfigChanged while connections are active. +/// Returns whether the instance was written and the locations that lost auto-connect. pub async fn poll_instance_with_events( transaction: &mut Transaction<'_, Sqlite>, instance: &mut Instance, handle: &AppHandle, -) -> Result { +) -> Result<(bool, Vec), Error> { let has_active_connections = !active_connections(instance).await?.is_empty(); let result = poll_instance(transaction, instance, has_active_connections).await?; let changed = instance_changed(&result); + let auto_connect_disabled = disabled_auto_connect(&result).to_vec(); emit_version_mismatch(handle, instance.id, version_mismatch(&result)); emit_poll_result_events(handle, instance.id, &instance.name, result); - Ok(changed) + Ok((changed, auto_connect_disabled)) +} + +fn disabled_auto_connect(result: &PollInstanceResult) -> &[String] { + match result { + PollInstanceResult::Updated { + auto_connect_disabled, + .. + } => auto_connect_disabled, + PollInstanceResult::Unchanged { .. } | PollInstanceResult::ChangedWhileActive { .. } => &[], + } } /// Whether the poll wrote to the instance, which the tray menu is built from. diff --git a/src-tauri/src/window_manager/mod.rs b/src-tauri/src/window_manager/mod.rs index 6a34cb5ff..32575b494 100644 --- a/src-tauri/src/window_manager/mod.rs +++ b/src-tauri/src/window_manager/mod.rs @@ -6,7 +6,7 @@ use tauri::{ use crate::{ appstate::AppState, - commands::{build_instance_info, build_location_info}, + commands::{build_instance_info, build_location_info, load_auto_connect_context}, connection::active_connections::get_connection_id_by_type, database::{ models::{ @@ -319,7 +319,12 @@ pub async fn initiate_configure_factor_screen( let location = match location_id { Some(location_id) => { if let Some(location) = Location::find_by_id(&*DB_POOL, location_id).await? { - Some(build_location_info(location, &connected_location_ids)) + let auto_connect_context = load_auto_connect_context().await?; + Some(build_location_info( + location, + &connected_location_ids, + &auto_connect_context, + )) } else { warn!("Configure factors requested from unknown location {location_id}"); None