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