From 787e605a603980a20b6dad5d38e90454e4563f5c Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 22 Nov 2024 18:52:03 -0500 Subject: [PATCH 01/27] feat(client/linux): revamp the Linux routing logic --- client/electron/go_plugin.ts | 2 +- client/electron/index.ts | 21 ++++-- client/electron/vpn_service.ts | 35 ++++++++++ client/go/outline/electron/go_plugin.go | 13 ++++ client/go/outline/electron/routing.go | 65 +++++++++++++++++++ client/go/outline/electron/routing_linux.go | 23 +++++++ client/go/outline/electron/routing_windows.go | 24 +++++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 client/electron/vpn_service.ts create mode 100644 client/go/outline/electron/routing.go create mode 100644 client/go/outline/electron/routing_linux.go create mode 100644 client/go/outline/electron/routing_windows.go diff --git a/client/electron/go_plugin.ts b/client/electron/go_plugin.ts index db80eaf22a..51bc29c215 100644 --- a/client/electron/go_plugin.ts +++ b/client/electron/go_plugin.ts @@ -20,7 +20,7 @@ import {pathToBackendLibrary} from './app_paths'; let invokeGoAPIFunc: Function | undefined; -export type GoApiName = 'FetchResource'; +export type GoApiName = 'FetchResource' | 'EstablishVPN'; /** * Calls a Go function by invoking the `InvokeGoAPI` function in the native backend library. diff --git a/client/electron/index.ts b/client/electron/index.ts index 89a9f1abb9..797f90cb8f 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -39,6 +39,7 @@ import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; import {VpnTunnel} from './vpn_tunnel'; +import {closeVpn, establishVpn} from './vpn_service'; import * as config from '../src/www/app/outline_server_repository/config'; import { StartRequestJson, @@ -56,7 +57,7 @@ declare const APP_VERSION: string; // Run-time environment variables: const debugMode = process.env.OUTLINE_DEBUG === 'true'; -const isLinux = os.platform() === 'linux'; +const IS_LINUX = os.platform() === 'linux'; // Used for the auto-connect feature. There will be a tunnel in store // if the user was connected at shutdown. @@ -158,7 +159,7 @@ function setupWindow(): void { // // The ideal solution would be: either electron-builder supports the app icon; or we add // dpi-aware features to this app. - if (isLinux) { + if (IS_LINUX) { mainWindow.setIcon( path.join( app.getAppPath(), @@ -251,7 +252,7 @@ function updateTray(status: TunnelStatus) { {type: 'separator'} as MenuItemConstructorOptions, {label: localizedStrings['quit'], click: quitApp}, ]; - if (isLinux) { + if (IS_LINUX) { // Because the click event is never fired on Linux, we need an explicit open option. menuTemplate = [ { @@ -309,7 +310,7 @@ function interceptShadowsocksLink(argv: string[]) { async function setupAutoLaunch(request: StartRequestJson): Promise { try { await tunnelStore.save(request); - if (isLinux) { + if (IS_LINUX) { if (process.env.APPIMAGE) { const outlineAutoLauncher = new autoLaunch({ name: 'OutlineClient', @@ -327,7 +328,7 @@ async function setupAutoLaunch(request: StartRequestJson): Promise { async function tearDownAutoLaunch() { try { - if (isLinux) { + if (IS_LINUX) { const outlineAutoLauncher = new autoLaunch({ name: 'OutlineClient', }); @@ -368,6 +369,11 @@ async function createVpnTunnel( // Invoked by both the start-proxying event handler and auto-connect. async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { + if (IS_LINUX) { + await establishVpn(request); + return; + } + if (currentTunnel) { throw new Error('already connected'); } @@ -401,6 +407,11 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { // Invoked by both the stop-proxying event and quit handler. async function stopVpn() { + if (IS_LINUX) { + await closeVpn(); + return; + } + if (!currentTunnel) { return; } diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts new file mode 100644 index 0000000000..8d6703b2b1 --- /dev/null +++ b/client/electron/vpn_service.ts @@ -0,0 +1,35 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; +import {invokeGoApi} from './go_plugin'; + +interface VpnConfig { + interfaceName: string; + ipAddress: string; + dnsServers: string[]; + transport: string; +} + +export async function establishVpn(transportConfig: TransportConfigJson) { + const config: VpnConfig = { + interfaceName: 'outline-tun0', + ipAddress: '10.0.85.2', + dnsServers: ['9.9.9.9'], + transport: JSON.stringify(transportConfig), + }; + const connection = await invokeGoApi('EstablishVPN', JSON.stringify(config)); +} + +export async function closeVpn() {} diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index 9fcff7d12e..2fdc989442 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -48,6 +48,12 @@ const ( // - Input: the URL string of the resource to fetch // - Output: the content in raw string of the fetched resource FetchResourceAPI = "FetchResource" + + // EstablishVPNAPI initiates a VPN connection and directs all network traffic through Outline. + // + // - Input: a JSON string of [VPNConfig]. + // - Output: a JSON string of [VPNConnection]. + EstablishVPNAPI = "EstablishVPN" ) // InvokeGoAPI is the unified entry point for TypeScript to invoke various Go functions. @@ -69,6 +75,13 @@ func InvokeGoAPI(api *C.char, input *C.char) C.InvokeGoAPIResult { ErrorJson: marshalCGoErrorJson(platerrors.ToPlatformError(res.Error)), } + case EstablishVPNAPI: + res, err := EstablishVPN(C.GoString(input)) + return C.InvokeGoAPIResult{ + Output: newCGoString(res), + ErrorJson: marshalCGoErrorJson(err), + } + default: err := &platerrors.PlatformError{ Code: platerrors.InternalError, diff --git a/client/go/outline/electron/routing.go b/client/go/outline/electron/routing.go new file mode 100644 index 0000000000..253dfe56a8 --- /dev/null +++ b/client/go/outline/electron/routing.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +type VPNConfig struct { + InterfaceName string `json:"interfaceName"` + IPAddress string `json:"ipAddress"` + DNSServers []string `json:"dnsServers"` + TransportConfig string `json:"transport"` +} + +type VPNConnection struct { + RouteUDP bool `json:"routeUDP"` +} + +func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { + var config VPNConfig + err := json.Unmarshal([]byte(configStr), &config) + if err != nil { + return "", &platerrors.PlatformError{ + Code: platerrors.IllegalConfig, + Message: "illegal VPN config format", + Cause: platerrors.ToPlatformError(err), + } + } + + conn, perr := establishVPN(&config) + if perr != nil { + return "", perr + } + + if conn == nil { + return "", &platerrors.PlatformError{ + Code: platerrors.InternalError, + Message: "unexpected nil VPN connection", + } + } + connJson, err := json.Marshal(conn) + if err != nil { + return "", &platerrors.PlatformError{ + Code: platerrors.InternalError, + Message: "failed to marshal VPN connection", + Cause: platerrors.ToPlatformError(err), + } + } + return string(connJson), nil +} diff --git a/client/go/outline/electron/routing_linux.go b/client/go/outline/electron/routing_linux.go new file mode 100644 index 0000000000..3cdc821277 --- /dev/null +++ b/client/go/outline/electron/routing_linux.go @@ -0,0 +1,23 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +func establishVPN(config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { + return nil, platerrors.NewPlatformError(platerrors.InternalError, "Working in progress !!!") +} diff --git a/client/go/outline/electron/routing_windows.go b/client/go/outline/electron/routing_windows.go new file mode 100644 index 0000000000..c1b561437e --- /dev/null +++ b/client/go/outline/electron/routing_windows.go @@ -0,0 +1,24 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + +func establishVPN(config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { + return nil, &platerrors.PlatformError{ + Code: platerrors.InternalError, + Message: "not implemented yet", + } +} From 46b8a2597f050cbd3c3ffc57e96dd0e39a611e71 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 22 Nov 2024 19:55:31 -0500 Subject: [PATCH 02/27] Implement TUN device configuration --- client/electron/index.ts | 2 +- client/electron/vpn_service.ts | 8 ++- .../outline/electron/{routing.go => vpn.go} | 11 +++- client/go/outline/electron/vpn_linux.go | 53 +++++++++++++++++++ .../{routing_windows.go => vpn_windows.go} | 8 ++- .../outline_linux.go} | 7 +-- .../go/outline/electron/vpnlinux/tun_linux.go | 47 ++++++++++++++++ 7 files changed, 126 insertions(+), 10 deletions(-) rename client/go/outline/electron/{routing.go => vpn.go} (87%) create mode 100644 client/go/outline/electron/vpn_linux.go rename client/go/outline/electron/{routing_windows.go => vpn_windows.go} (78%) rename client/go/outline/electron/{routing_linux.go => vpnlinux/outline_linux.go} (78%) create mode 100644 client/go/outline/electron/vpnlinux/tun_linux.go diff --git a/client/electron/index.ts b/client/electron/index.ts index 797f90cb8f..b8ddfa0e7c 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -38,8 +38,8 @@ import {GoApiName, invokeGoApi} from './go_plugin'; import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; -import {VpnTunnel} from './vpn_tunnel'; import {closeVpn, establishVpn} from './vpn_service'; +import {VpnTunnel} from './vpn_tunnel'; import * as config from '../src/www/app/outline_server_repository/config'; import { StartRequestJson, diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 8d6703b2b1..f3fc993290 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; import {invokeGoApi} from './go_plugin'; +import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; interface VpnConfig { interfaceName: string; @@ -29,7 +29,11 @@ export async function establishVpn(transportConfig: TransportConfigJson) { dnsServers: ['9.9.9.9'], transport: JSON.stringify(transportConfig), }; - const connection = await invokeGoApi('EstablishVPN', JSON.stringify(config)); + const connectionJson = await invokeGoApi( + 'EstablishVPN', + JSON.stringify(config) + ); + console.info(JSON.parse(connectionJson)); } export async function closeVpn() {} diff --git a/client/go/outline/electron/routing.go b/client/go/outline/electron/vpn.go similarity index 87% rename from client/go/outline/electron/routing.go rename to client/go/outline/electron/vpn.go index 253dfe56a8..d69682e619 100644 --- a/client/go/outline/electron/routing.go +++ b/client/go/outline/electron/vpn.go @@ -15,9 +15,12 @@ package main import ( + "context" "encoding/json" + "io" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" ) type VPNConfig struct { @@ -28,7 +31,11 @@ type VPNConfig struct { } type VPNConnection struct { - RouteUDP bool `json:"routeUDP"` + Status string `json:"status"` + RouteUDP bool `json:"routeUDP"` + + tun io.ReadWriteCloser `json:"-"` + outline network.IPDevice } func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { @@ -42,7 +49,7 @@ func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { } } - conn, perr := establishVPN(&config) + conn, perr := establishVPN(context.TODO(), &config) if perr != nil { return "", perr } diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go new file mode 100644 index 0000000000..ebb192d9f9 --- /dev/null +++ b/client/go/outline/electron/vpn_linux.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +func establishVPN(ctx context.Context, config *VPNConfig) (_ *VPNConnection, perr *platerrors.PlatformError) { + conn := &VPNConnection{} + + if conn.tun, perr = vpnlinux.ConfigureTUNDevice(config.InterfaceName); perr != nil { + return nil, perr + } + defer func() { + if perr != nil { + conn.tun.Close() + } + }() + + // Configure Network Manager connection + + // Create Outline socket and protect it + if conn.outline, perr = vpnlinux.ConfigureOutlineDevice(config.TransportConfig); perr != nil { + return nil, perr + } + defer func() { + if perr != nil { + conn.outline.Close() + } + }() + + // Create routing table + + // Add IP rule to route all traffic to outline + + return conn, nil +} diff --git a/client/go/outline/electron/routing_windows.go b/client/go/outline/electron/vpn_windows.go similarity index 78% rename from client/go/outline/electron/routing_windows.go rename to client/go/outline/electron/vpn_windows.go index c1b561437e..34830532c7 100644 --- a/client/go/outline/electron/routing_windows.go +++ b/client/go/outline/electron/vpn_windows.go @@ -14,9 +14,13 @@ package main -import "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +import ( + "context" -func establishVPN(config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +func establishVPN(ctx context.Context, config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { return nil, &platerrors.PlatformError{ Code: platerrors.InternalError, Message: "not implemented yet", diff --git a/client/go/outline/electron/routing_linux.go b/client/go/outline/electron/vpnlinux/outline_linux.go similarity index 78% rename from client/go/outline/electron/routing_linux.go rename to client/go/outline/electron/vpnlinux/outline_linux.go index 3cdc821277..1962246c41 100644 --- a/client/go/outline/electron/routing_linux.go +++ b/client/go/outline/electron/vpnlinux/outline_linux.go @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package vpnlinux import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" ) -func establishVPN(config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { - return nil, platerrors.NewPlatformError(platerrors.InternalError, "Working in progress !!!") +func ConfigureOutlineDevice(transportConfig string) (network.IPDevice, *platerrors.PlatformError) { + return nil, nil } diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go new file mode 100644 index 0000000000..682157a480 --- /dev/null +++ b/client/go/outline/electron/vpnlinux/tun_linux.go @@ -0,0 +1,47 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpnlinux + +import ( + "io" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/songgao/water" +) + +func ConfigureTUNDevice(name string) (_ io.ReadWriteCloser, perr *platerrors.PlatformError) { + tun, err := water.New(water.Config{ + DeviceType: water.TUN, + PlatformSpecificParams: water.PlatformSpecificParams{ + Name: name, + Persist: false, + }, + }) + if err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to open the TUN device", + Details: platerrors.ErrorDetails{"name": name}, + Cause: platerrors.ToPlatformError(err), + } + } + defer func() { + if perr != nil { + tun.Close() + } + }() + + return tun, nil +} From d5f1b8868539e0669451d41c3ab917d61a1f6804 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Tue, 26 Nov 2024 16:57:23 -0500 Subject: [PATCH 03/27] add skeleton of the routing logic --- client/electron/go_plugin.ts | 6 +- client/electron/vpn_service.ts | 12 ++- client/go/outline/electron/go_plugin.go | 11 +++ client/go/outline/electron/outline_device.go | 79 +++++++++++++++++++ client/go/outline/electron/vpn.go | 26 ++++-- client/go/outline/electron/vpn_linux.go | 47 ++++++++--- client/go/outline/electron/vpn_windows.go | 9 ++- .../outline/electron/vpnlinux/iprule_linux.go | 58 ++++++++++++++ .../{outline_linux.go => nmconn_linux.go} | 14 +++- .../electron/vpnlinux/routing_linux.go | 37 +++++++++ .../go/outline/electron/vpnlinux/tun_linux.go | 25 +++++- client/go/outline/platerrors/error_code.go | 4 + go.mod | 6 +- go.sum | 12 +++ 14 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 client/go/outline/electron/outline_device.go create mode 100644 client/go/outline/electron/vpnlinux/iprule_linux.go rename client/go/outline/electron/vpnlinux/{outline_linux.go => nmconn_linux.go} (66%) create mode 100644 client/go/outline/electron/vpnlinux/routing_linux.go diff --git a/client/electron/go_plugin.ts b/client/electron/go_plugin.ts index 51bc29c215..e9a3817484 100644 --- a/client/electron/go_plugin.ts +++ b/client/electron/go_plugin.ts @@ -20,7 +20,7 @@ import {pathToBackendLibrary} from './app_paths'; let invokeGoAPIFunc: Function | undefined; -export type GoApiName = 'FetchResource' | 'EstablishVPN'; +export type GoApiName = 'FetchResource' | 'EstablishVPN' | 'CloseVPN'; /** * Calls a Go function by invoking the `InvokeGoAPI` function in the native backend library. @@ -58,9 +58,9 @@ export async function invokeGoApi( ); } - console.debug('[Backend] - calling InvokeGoAPI ...'); + console.debug(`[Backend] - calling InvokeGoAPI "${api}" ...`); const result = await invokeGoAPIFunc(api, input); - console.debug('[Backend] - InvokeGoAPI returned', result); + console.debug(`[Backend] - GoAPI ${api} returned`, result); if (result.ErrorJson) { throw Error(result.ErrorJson); } diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index f3fc993290..e1d1cba735 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -13,21 +13,23 @@ // limitations under the License. import {invokeGoApi} from './go_plugin'; -import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; +import {StartRequestJson} from '../src/www/app/outline_server_repository/vpn'; interface VpnConfig { interfaceName: string; ipAddress: string; dnsServers: string[]; + routingTableId: number; transport: string; } -export async function establishVpn(transportConfig: TransportConfigJson) { +export async function establishVpn(request: StartRequestJson) { const config: VpnConfig = { interfaceName: 'outline-tun0', ipAddress: '10.0.85.2', dnsServers: ['9.9.9.9'], - transport: JSON.stringify(transportConfig), + routingTableId: 13579, + transport: JSON.stringify(request.config.transport), }; const connectionJson = await invokeGoApi( 'EstablishVPN', @@ -36,4 +38,6 @@ export async function establishVpn(transportConfig: TransportConfigJson) { console.info(JSON.parse(connectionJson)); } -export async function closeVpn() {} +export async function closeVpn(): Promise { + await invokeGoApi('CloseVPN', ''); +} diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index 2fdc989442..cc7a0812bf 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -54,6 +54,13 @@ const ( // - Input: a JSON string of [VPNConfig]. // - Output: a JSON string of [VPNConnection]. EstablishVPNAPI = "EstablishVPN" + + // CloseVPNAPI closes an existing VPN connection and restores network traffic to the default + // network interface. + // + // - Input: null + // - Output: null + CloseVPNAPI = "CloseVPN" ) // InvokeGoAPI is the unified entry point for TypeScript to invoke various Go functions. @@ -82,6 +89,10 @@ func InvokeGoAPI(api *C.char, input *C.char) C.InvokeGoAPIResult { ErrorJson: marshalCGoErrorJson(err), } + case CloseVPNAPI: + err := CloseVPN() + return C.InvokeGoAPIResult{ErrorJson: marshalCGoErrorJson(err)} + default: err := &platerrors.PlatformError{ Code: platerrors.InternalError, diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go new file mode 100644 index 0000000000..9e7e2c5237 --- /dev/null +++ b/client/go/outline/electron/outline_device.go @@ -0,0 +1,79 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "log/slog" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" + "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" +) + +type outlineDevice struct { + network.IPDevice + network.DelegatePacketProxy + + remote, fallback network.PacketProxy +} + +func configureOutlineDevice(transportConfig string) (*outlineDevice, *platerrors.PlatformError) { + var err error + dev := &outlineDevice{} + + res := outline.NewClient(transportConfig) + if res.Error != nil { + return nil, res.Error + } + + dev.remote, err = network.NewPacketProxyFromPacketListener(res.Client.PacketListener) + if err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupTrafficHandlerFailed, + Message: "failed to create datagram handler", + Cause: platerrors.ToPlatformError(err), + } + } + + if dev.fallback, err = dnstruncate.NewPacketProxy(); err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupTrafficHandlerFailed, + Message: "failed to create datagram handler for DNS fallback", + Cause: platerrors.ToPlatformError(err), + } + } + + if dev.DelegatePacketProxy, err = network.NewDelegatePacketProxy(dev.remote); err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupTrafficHandlerFailed, + Message: "failed to combine datagram handlers", + Cause: platerrors.ToPlatformError(err), + } + } + + dev.IPDevice, err = lwip2transport.ConfigureDevice(res.Client.StreamDialer, dev) + if err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupTrafficHandlerFailed, + Message: "failed to configure network stack", + Cause: platerrors.ToPlatformError(err), + } + } + + slog.Info("successfully configured outline device") + return dev, nil +} diff --git a/client/go/outline/electron/vpn.go b/client/go/outline/electron/vpn.go index d69682e619..15916f412e 100644 --- a/client/go/outline/electron/vpn.go +++ b/client/go/outline/electron/vpn.go @@ -17,16 +17,17 @@ package main import ( "context" "encoding/json" - "io" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/songgao/water" + "github.com/vishvananda/netlink" ) type VPNConfig struct { InterfaceName string `json:"interfaceName"` IPAddress string `json:"ipAddress"` DNSServers []string `json:"dnsServers"` + RoutingTableId int `json:"routingTableId"` TransportConfig string `json:"transport"` } @@ -34,11 +35,14 @@ type VPNConnection struct { Status string `json:"status"` RouteUDP bool `json:"routeUDP"` - tun io.ReadWriteCloser `json:"-"` - outline network.IPDevice + tun *water.Interface `json:"-"` + outline *outlineDevice `json:"-"` + ipRule *netlink.Rule `json:"-"` } -func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { +var conn *VPNConnection + +func EstablishVPN(configStr string) (_ string, perr *platerrors.PlatformError) { var config VPNConfig err := json.Unmarshal([]byte(configStr), &config) if err != nil { @@ -49,9 +53,8 @@ func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { } } - conn, perr := establishVPN(context.TODO(), &config) - if perr != nil { - return "", perr + if conn, perr = establishVPN(context.TODO(), &config); perr != nil { + return } if conn == nil { @@ -70,3 +73,10 @@ func EstablishVPN(configStr string) (string, *platerrors.PlatformError) { } return string(connJson), nil } + +func CloseVPN() *platerrors.PlatformError { + if conn == nil { + return nil + } + return closeVPNConn(conn) +} diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index ebb192d9f9..1366d547ef 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -16,38 +16,67 @@ package main import ( "context" + "log/slog" "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) func establishVPN(ctx context.Context, config *VPNConfig) (_ *VPNConnection, perr *platerrors.PlatformError) { + slog.Debug("establishing VPN connection ...", "config", config) conn := &VPNConnection{} + // Create Outline socket and protect it + if conn.outline, perr = configureOutlineDevice(config.TransportConfig); perr != nil { + return + } + defer func() { + if perr != nil { + conn.outline.Close() + } + }() + + // Create and configure the TUN device if conn.tun, perr = vpnlinux.ConfigureTUNDevice(config.InterfaceName); perr != nil { return nil, perr } defer func() { if perr != nil { - conn.tun.Close() + vpnlinux.CloseTUNDevice(conn.tun) } }() - // Configure Network Manager connection + // Create and configure Network Manager connection + if perr = vpnlinux.ConfigureNMConnection(); perr != nil { + return + } + + // Create and configure Outline routing table + if perr = vpnlinux.ConfigureRoutingTable(config.InterfaceName, config.RoutingTableId); perr != nil { + return + } - // Create Outline socket and protect it - if conn.outline, perr = vpnlinux.ConfigureOutlineDevice(config.TransportConfig); perr != nil { - return nil, perr + // Add IP rule to route all traffic to Outline routing table + if conn.ipRule, perr = vpnlinux.AddIPRule(config.RoutingTableId, 13579); perr != nil { + return } defer func() { if perr != nil { - conn.outline.Close() + vpnlinux.DelIPRule(conn.ipRule) } }() - // Create routing table + slog.Info("VPN connection established", "conn", conn) + return conn, nil +} - // Add IP rule to route all traffic to outline +func closeVPNConn(conn *VPNConnection) (perr *platerrors.PlatformError) { + if perr = vpnlinux.DelIPRule(conn.ipRule); perr != nil { + return + } - return conn, nil + // All following errors can be ignored + conn.outline.Close() + vpnlinux.CloseTUNDevice(conn.tun) + return nil } diff --git a/client/go/outline/electron/vpn_windows.go b/client/go/outline/electron/vpn_windows.go index 34830532c7..30de9e133c 100644 --- a/client/go/outline/electron/vpn_windows.go +++ b/client/go/outline/electron/vpn_windows.go @@ -20,9 +20,16 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -func establishVPN(ctx context.Context, config *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { +func establishVPN(context.Context, *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { return nil, &platerrors.PlatformError{ Code: platerrors.InternalError, Message: "not implemented yet", } } + +func closeVPNConn(*VPNConnection) *platerrors.PlatformError { + return &platerrors.PlatformError{ + Code: platerrors.InternalError, + Message: "not implemented yet", + } +} diff --git a/client/go/outline/electron/vpnlinux/iprule_linux.go b/client/go/outline/electron/vpnlinux/iprule_linux.go new file mode 100644 index 0000000000..dad43f776d --- /dev/null +++ b/client/go/outline/electron/vpnlinux/iprule_linux.go @@ -0,0 +1,58 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpnlinux + +import ( + "log/slog" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/vishvananda/netlink" +) + +func AddIPRule(tableId int, fwMark uint32) (*netlink.Rule, *platerrors.PlatformError) { + rule := netlink.NewRule() + // rule.Priority = 1357 + rule.Table = tableId + rule.Mark = fwMark + // rule.Invert = true + + if err := netlink.RuleAdd(rule); err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to add ip rule to Outline routing table", + Cause: platerrors.ToPlatformError(err), + } + } + + slog.Info("successfully added IP rule", "rule", rule) + return rule, nil +} + +func DelIPRule(rule *netlink.Rule) *platerrors.PlatformError { + if rule == nil { + return nil + } + if err := netlink.RuleDel(rule); err != nil { + slog.Error("failed to remove IP rule", "rule", rule, "err", err) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "failed to remove the ip rule to Outline routing table", + Cause: platerrors.ToPlatformError(err), + } + } + + slog.Info("successfully removed IP rule", "rule", rule) + return nil +} diff --git a/client/go/outline/electron/vpnlinux/outline_linux.go b/client/go/outline/electron/vpnlinux/nmconn_linux.go similarity index 66% rename from client/go/outline/electron/vpnlinux/outline_linux.go rename to client/go/outline/electron/vpnlinux/nmconn_linux.go index 1962246c41..fe8758c713 100644 --- a/client/go/outline/electron/vpnlinux/outline_linux.go +++ b/client/go/outline/electron/vpnlinux/nmconn_linux.go @@ -16,9 +16,17 @@ package vpnlinux import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Wifx/gonetworkmanager/v2" ) -func ConfigureOutlineDevice(transportConfig string) (network.IPDevice, *platerrors.PlatformError) { - return nil, nil +func ConfigureNMConnection() *platerrors.PlatformError { + _, err := gonetworkmanager.NewSettings() + if err != nil { + return &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to connect to NetworkManager", + Cause: platerrors.ToPlatformError(err), + } + } + return nil } diff --git a/client/go/outline/electron/vpnlinux/routing_linux.go b/client/go/outline/electron/vpnlinux/routing_linux.go new file mode 100644 index 0000000000..4e8e658bd8 --- /dev/null +++ b/client/go/outline/electron/vpnlinux/routing_linux.go @@ -0,0 +1,37 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpnlinux + +import ( + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/vishvananda/netlink" +) + +func ConfigureRoutingTable(tunName string, tableId int) *platerrors.PlatformError { + tun, err := netlink.LinkByName(tunName) + if err != nil { + return &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to locate TUN device from the netlink API", + Cause: platerrors.ToPlatformError(err), + } + } + + _ = netlink.Route{ + LinkIndex: tun.Attrs().Index, + Table: tableId, + } + return nil +} diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go index 682157a480..a69f892185 100644 --- a/client/go/outline/electron/vpnlinux/tun_linux.go +++ b/client/go/outline/electron/vpnlinux/tun_linux.go @@ -15,13 +15,13 @@ package vpnlinux import ( - "io" + "log/slog" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/songgao/water" ) -func ConfigureTUNDevice(name string) (_ io.ReadWriteCloser, perr *platerrors.PlatformError) { +func ConfigureTUNDevice(name string) (_ *water.Interface, perr *platerrors.PlatformError) { tun, err := water.New(water.Config{ DeviceType: water.TUN, PlatformSpecificParams: water.PlatformSpecificParams{ @@ -39,9 +39,28 @@ func ConfigureTUNDevice(name string) (_ io.ReadWriteCloser, perr *platerrors.Pla } defer func() { if perr != nil { - tun.Close() + CloseTUNDevice(tun) } }() + slog.Info("successfully configured TUN device", "name", tun.Name()) return tun, nil } + +func CloseTUNDevice(tun *water.Interface) *platerrors.PlatformError { + if tun == nil { + return nil + } + if err := tun.Close(); err != nil { + slog.Error("failed to close TUN device", "name", tun.Name(), "err", err) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "failed to close the TUN device", + Details: platerrors.ErrorDetails{"name": tun.Name()}, + Cause: platerrors.ToPlatformError(err), + } + } + + slog.Info("successfully closed TUN device", "name", tun.Name()) + return nil +} diff --git a/client/go/outline/platerrors/error_code.go b/client/go/outline/platerrors/error_code.go index a51f8bf722..4175ab9d08 100644 --- a/client/go/outline/platerrors/error_code.go +++ b/client/go/outline/platerrors/error_code.go @@ -56,6 +56,10 @@ const ( // SetupSystemVPNFailed means we failed to configure the system VPN to route to us. SetupSystemVPNFailed ErrorCode = "ERR_SYSTEM_VPN_SETUP_FAILURE" + // DisconnectSystemVPNFailed means we failed to remove the system VPN. + // This typically indicates that a reboot is needed. + DisconnectSystemVPNFailed ErrorCode = "ERR_SYSTEM_VPN_DISCONNECT_FAILURE" + // DataTransmissionFailed means we failed to copy data from one device to another. DataTransmissionFailed ErrorCode = "ERR_DATA_TRANSMISSION_FAILURE" ) diff --git a/go.mod b/go.mod index cbe671c5ca..819e66a67d 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,14 @@ go 1.21 require ( github.com/Jigsaw-Code/outline-sdk v0.0.14-0.20240216220040-f741c57bf854 + github.com/Wifx/gonetworkmanager/v2 v2.1.0 github.com/eycorsican/go-tun2socks v1.16.11 github.com/go-task/task/v3 v3.36.0 github.com/google/addlicense v1.1.1 github.com/google/go-licenses v1.6.0 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.9.0 + github.com/vishvananda/netlink v1.3.0 golang.org/x/mobile v0.0.0-20240716161057-1ad2df20a8b6 golang.org/x/sys v0.22.0 ) @@ -21,6 +24,7 @@ require ( github.com/fatih/color v1.16.0 // indirect github.com/go-logr/logr v1.2.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect @@ -39,10 +43,10 @@ require ( github.com/sajari/fuzzy v1.0.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect - github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect github.com/spf13/cobra v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/src-d/gcfg v1.4.0 // indirect + github.com/vishvananda/netns v0.0.4 // indirect github.com/xanzy/ssh-agent v0.2.1 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opencensus.io v0.23.0 // indirect diff --git a/go.sum b/go.sum index 4d28c77ddb..cb377b4be7 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/Jigsaw-Code/outline-sdk v0.0.14-0.20240216220040-f741c57bf854/go.mod github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/Wifx/gonetworkmanager/v2 v2.1.0 h1:2PNs7P6wgOyc57YK7AKMwNxGCLvWU6zFBXoEILV4at8= +github.com/Wifx/gonetworkmanager/v2 v2.1.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= @@ -128,6 +130,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/task/v3 v3.36.0 h1:XVJ5hQ5hdzTAulHpAGzbUMUuYr9MUOEQFOFazI3hUsY= github.com/go-task/task/v3 v3.36.0/go.mod h1:XBCIAzuyG/mgZVHMUm3cCznz4+IpsBQRlW1gw7OA5sA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -185,6 +189,8 @@ github.com/google/go-licenses v1.6.0 h1:MM+VCXf0slYkpWO0mECvdYDVCxZXIQNal5wqUIXE github.com/google/go-licenses v1.6.0/go.mod h1:Z8jgz2isEhdenOqd/00pq7I4y4k1xVVQJv415otjclo= github.com/google/go-replayers/httpreplay v1.1.1 h1:H91sIMlt1NZzN7R+/ASswyouLJfW0WLW7fhyUFvDEkY= github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 h1:TJsAqW6zLRMDTyGmc9TPosfn9OyVlHs8Hrn3pY6ONSY= github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148/go.mod h1:rq9F0RSpNKlrefnf6ZYMHKUnEJBCNzf6AcCXMYBeYvE= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -318,6 +324,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= +github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -543,8 +553,10 @@ golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= From 7b7c4854ccef9d689db23c15c3499bc990d57c23 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Sat, 30 Nov 2024 22:07:07 -0500 Subject: [PATCH 04/27] Implement TCP IPv4 Routing --- client/electron/debian/after_install.sh | 12 ++ client/electron/electron-builder.json | 7 +- client/electron/index.ts | 3 +- client/electron/vpn_service.ts | 6 +- client/go/outline/client.go | 30 +++-- client/go/outline/electron/outline_device.go | 31 ++++- client/go/outline/electron/vpn.go | 22 ++-- client/go/outline/electron/vpn_linux.go | 97 ++++++++++----- client/go/outline/electron/vpn_windows.go | 5 + .../outline/electron/vpnlinux/iprule_linux.go | 11 +- .../outline/electron/vpnlinux/nmconn_linux.go | 110 +++++++++++++++-- .../electron/vpnlinux/routing_linux.go | 65 ++++++++-- .../go/outline/electron/vpnlinux/tun_linux.go | 111 +++++++++++++++--- 13 files changed, 401 insertions(+), 109 deletions(-) diff --git a/client/electron/debian/after_install.sh b/client/electron/debian/after_install.sh index f2a785aae1..1e01a37ade 100644 --- a/client/electron/debian/after_install.sh +++ b/client/electron/debian/after_install.sh @@ -16,10 +16,22 @@ # Dependencies: # - libcap2-bin: setcap +# - patchelf: patchelf set -eux +# Capabilitites will disable LD_LIBRARY_PATH, and $ORIGIN evaluation in binary's +# rpath. So we need to set the rpath to an absolute path. (for libffmpeg.so) +# This command will also reset capabilitites, so we need to run this before setcap. +/usr/bin/patchelf --add-rpath /opt/Outline /opt/Outline/Outline + # Grant specific capabilities so Outline can run without root permisssion # - cap_net_admin: configure network interfaces, set up routing tables, etc. # - cap_dac_override: modify network configuration files owned by root /usr/sbin/setcap cap_net_admin,cap_dac_override+eip /opt/Outline/Outline + +# From electron's hint: +# > The SUID sandbox helper binary was found, but is not configured correctly. +# > Rather than run without sandboxing I'm aborting now. You need to make sure +# > that /opt/Outline/chrome-sandbox is owned by root and has mode 4755. +/usr/bin/chmod 4755 /opt/Outline/chrome-sandbox diff --git a/client/electron/electron-builder.json b/client/electron/electron-builder.json index b2227c1e00..a259fa0748 100644 --- a/client/electron/electron-builder.json +++ b/client/electron/electron-builder.json @@ -20,7 +20,7 @@ "deb": { "depends": [ "gconf2", "gconf-service", "libnotify4", "libappindicator1", "libxtst6", "libnss3", - "libcap2-bin" + "libcap2-bin", "patchelf" ], "afterInstall": "client/electron/debian/after_install.sh" }, @@ -36,11 +36,6 @@ "icon": "client/electron/icons/png", "maintainer": "Jigsaw LLC", "target": [{ - "arch": [ - "x64" - ], - "target": "AppImage" - }, { "arch": "x64", "target": "deb" }] diff --git a/client/electron/index.ts b/client/electron/index.ts index b8ddfa0e7c..f69611823e 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -371,6 +371,7 @@ async function createVpnTunnel( async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { if (IS_LINUX) { await establishVpn(request); + setUiTunnelStatus(TunnelStatus.CONNECTED, request.id); return; } @@ -408,7 +409,7 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { // Invoked by both the stop-proxying event and quit handler. async function stopVpn() { if (IS_LINUX) { - await closeVpn(); + await Promise.all([closeVpn(), tearDownAutoLaunch()]); return; } diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index e1d1cba735..7e68d2ef98 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -20,15 +20,17 @@ interface VpnConfig { ipAddress: string; dnsServers: string[]; routingTableId: number; + protectionMark: number; transport: string; } export async function establishVpn(request: StartRequestJson) { const config: VpnConfig = { interfaceName: 'outline-tun0', - ipAddress: '10.0.85.2', - dnsServers: ['9.9.9.9'], + ipAddress: '10.0.85.5', + dnsServers: ['8.8.4.4'], routingTableId: 13579, + protectionMark: 24680, transport: JSON.stringify(request.config.transport), }; const connectionJson = await invokeGoApi( diff --git a/client/go/outline/client.go b/client/go/outline/client.go index eef75dbe32..bb496fce46 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,23 +42,29 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - config, err := parseConfigFromJSON(transportConfig) + client, err := NewClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{}) + return &NewClientResult{ + Client: client, + Error: platerrors.ToPlatformError(err), + } +} + +func NewClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) { + conf, err := parseConfigFromJSON(transportConfig) if err != nil { - return &NewClientResult{Error: platerrors.ToPlatformError(err)} + return nil, err } - prefixBytes, err := ParseConfigPrefixFromString(config.Prefix) + prefixBytes, err := ParseConfigPrefixFromString(conf.Prefix) if err != nil { - return &NewClientResult{Error: platerrors.ToPlatformError(err)} + return nil, err } - client, err := newShadowsocksClient(config.Host, int(config.Port), config.Method, config.Password, prefixBytes) - return &NewClientResult{ - Client: client, - Error: platerrors.ToPlatformError(err), - } + return newShadowsocksClient(conf.Host, int(conf.Port), conf.Method, conf.Password, prefixBytes, tcpDialer, udpDialer) } -func newShadowsocksClient(host string, port int, cipherName, password string, prefix []byte) (*Client, error) { +func newShadowsocksClient( + host string, port int, cipherName, password string, prefix []byte, tcpDialer, udpDialer net.Dialer, +) (*Client, error) { if err := validateConfig(host, port, cipherName, password); err != nil { return nil, err } @@ -74,7 +80,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr // We disable Keep-Alive as per https://datatracker.ietf.org/doc/html/rfc1122#page-101, which states that it should only be // enabled in server applications. This prevents the device from unnecessarily waking up to send keep alives. - streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: net.Dialer{KeepAlive: -1}}, cryptoKey) + streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: tcpDialer}, cryptoKey) if err != nil { return nil, platerrors.PlatformError{ Code: platerrors.SetupTrafficHandlerFailed, @@ -88,7 +94,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr streamDialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(prefix) } - packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress}, cryptoKey) + packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress, Dialer: udpDialer}, cryptoKey) if err != nil { return nil, platerrors.PlatformError{ Code: platerrors.SetupTrafficHandlerFailed, diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go index 9e7e2c5237..df11de0a2f 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/electron/outline_device.go @@ -16,6 +16,8 @@ package main import ( "log/slog" + "net" + "syscall" "github.com/Jigsaw-Code/outline-apps/client/go/outline" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" @@ -31,16 +33,33 @@ type outlineDevice struct { remote, fallback network.PacketProxy } -func configureOutlineDevice(transportConfig string) (*outlineDevice, *platerrors.PlatformError) { +func configureOutlineDevice(transportConfig string, sockmark int) (*outlineDevice, *platerrors.PlatformError) { var err error dev := &outlineDevice{} - res := outline.NewClient(transportConfig) - if res.Error != nil { - return nil, res.Error + tcpDialer := net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + slog.Debug("Setting SO_MARK to TCP connection", "SO_MARK", sockmark) + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) + }) + }, + } + udpDialer := net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + slog.Debug("Setting SO_MARK to UDP connection", "SO_MARK", sockmark) + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) + }) + }, + } + + c, err := outline.NewClientWithBaseDialers(transportConfig, tcpDialer, udpDialer) + if err != nil { + return nil, platerrors.ToPlatformError(err) } - dev.remote, err = network.NewPacketProxyFromPacketListener(res.Client.PacketListener) + dev.remote, err = network.NewPacketProxyFromPacketListener(c.PacketListener) if err != nil { return nil, &platerrors.PlatformError{ Code: platerrors.SetupTrafficHandlerFailed, @@ -65,7 +84,7 @@ func configureOutlineDevice(transportConfig string) (*outlineDevice, *platerrors } } - dev.IPDevice, err = lwip2transport.ConfigureDevice(res.Client.StreamDialer, dev) + dev.IPDevice, err = lwip2transport.ConfigureDevice(c.StreamDialer, dev) if err != nil { return nil, &platerrors.PlatformError{ Code: platerrors.SetupTrafficHandlerFailed, diff --git a/client/go/outline/electron/vpn.go b/client/go/outline/electron/vpn.go index 15916f412e..162cb8c6bb 100644 --- a/client/go/outline/electron/vpn.go +++ b/client/go/outline/electron/vpn.go @@ -19,8 +19,6 @@ import ( "encoding/json" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/songgao/water" - "github.com/vishvananda/netlink" ) type VPNConfig struct { @@ -28,18 +26,10 @@ type VPNConfig struct { IPAddress string `json:"ipAddress"` DNSServers []string `json:"dnsServers"` RoutingTableId int `json:"routingTableId"` + ProtectionMark uint32 `json:"protectionMark"` TransportConfig string `json:"transport"` } -type VPNConnection struct { - Status string `json:"status"` - RouteUDP bool `json:"routeUDP"` - - tun *water.Interface `json:"-"` - outline *outlineDevice `json:"-"` - ipRule *netlink.Rule `json:"-"` -} - var conn *VPNConnection func EstablishVPN(configStr string) (_ string, perr *platerrors.PlatformError) { @@ -53,6 +43,9 @@ func EstablishVPN(configStr string) (_ string, perr *platerrors.PlatformError) { } } + if conn != nil { + CloseVPN() + } if conn, perr = establishVPN(context.TODO(), &config); perr != nil { return } @@ -74,9 +67,12 @@ func EstablishVPN(configStr string) (_ string, perr *platerrors.PlatformError) { return string(connJson), nil } -func CloseVPN() *platerrors.PlatformError { +func CloseVPN() (perr *platerrors.PlatformError) { if conn == nil { return nil } - return closeVPNConn(conn) + if perr = closeVPNConn(conn); perr == nil { + conn = nil + } + return } diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index 1366d547ef..629559496e 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -16,54 +16,69 @@ package main import ( "context" + "io" "log/slog" + "net" + "sync" "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/vishvananda/netlink" ) -func establishVPN(ctx context.Context, config *VPNConfig) (_ *VPNConnection, perr *platerrors.PlatformError) { - slog.Debug("establishing VPN connection ...", "config", config) - conn := &VPNConnection{} +type VPNConnection struct { + Status string `json:"status"` + RouteUDP bool `json:"routeUDP"` - // Create Outline socket and protect it - if conn.outline, perr = configureOutlineDevice(config.TransportConfig); perr != nil { - return - } - defer func() { - if perr != nil { - conn.outline.Close() - } - }() + ctx context.Context `json:"-"` + wg sync.WaitGroup `json:"-"` - // Create and configure the TUN device - if conn.tun, perr = vpnlinux.ConfigureTUNDevice(config.InterfaceName); perr != nil { - return nil, perr - } + outline *outlineDevice `json:"-"` + + tun *vpnlinux.TUNDevice `json:"-"` + nmConn gonetworkmanager.Connection `json:"-"` + table int `json:"-"` + rule *netlink.Rule `json:"-"` +} + +func establishVPN(ctx context.Context, conf *VPNConfig) (_ *VPNConnection, perr *platerrors.PlatformError) { + slog.Debug("establishing VPN connection ...", "config", conf) + conn := &VPNConnection{ctx: ctx} defer func() { if perr != nil { - vpnlinux.CloseTUNDevice(conn.tun) + closeVPNConn(conn) } }() - // Create and configure Network Manager connection - if perr = vpnlinux.ConfigureNMConnection(); perr != nil { + // Create Outline socket and protect it + if conn.outline, perr = configureOutlineDevice(conf.TransportConfig, int(conf.ProtectionMark)); perr != nil { return } - // Create and configure Outline routing table - if perr = vpnlinux.ConfigureRoutingTable(config.InterfaceName, config.RoutingTableId); perr != nil { + if conn.tun, perr = vpnlinux.ConfigureTUNDevice(conf.InterfaceName, conf.IPAddress); perr != nil { + return nil, perr + } + if conn.nmConn, perr = vpnlinux.ConfigureNMConnection(conn.tun, net.ParseIP(conf.DNSServers[0])); perr != nil { return } - - // Add IP rule to route all traffic to Outline routing table - if conn.ipRule, perr = vpnlinux.AddIPRule(config.RoutingTableId, 13579); perr != nil { + if perr = vpnlinux.ConfigureRoutingTable(conn.tun, conf.RoutingTableId); perr != nil { return } - defer func() { - if perr != nil { - vpnlinux.DelIPRule(conn.ipRule) - } + conn.table = conf.RoutingTableId + if conn.rule, perr = vpnlinux.AddIPRules(conf.RoutingTableId, conf.ProtectionMark); perr != nil { + return + } + + go func() { + slog.Debug("Copying traffic from TUN Device -> OutlineDevice...") + n, err := io.Copy(conn.outline, conn.tun.File) + slog.Debug("TUN Device -> OutlineDevice done", "n", n, "err", err) + }() + go func() { + slog.Debug("Copying traffic from OutlineDevice -> TUN Device...") + n, err := io.Copy(conn.tun.File, conn.outline) + slog.Debug("OutlineDevice -> TUN Device done", "n", n, "err", err) }() slog.Info("VPN connection established", "conn", conn) @@ -71,12 +86,30 @@ func establishVPN(ctx context.Context, config *VPNConfig) (_ *VPNConnection, per } func closeVPNConn(conn *VPNConnection) (perr *platerrors.PlatformError) { - if perr = vpnlinux.DelIPRule(conn.ipRule); perr != nil { - return + if conn == nil { + return nil } - // All following errors can be ignored - conn.outline.Close() + if conn.rule != nil { + if perr = vpnlinux.DeleteIPRules(conn.rule); perr != nil { + return + } + } + if conn.nmConn != nil { + if perr = vpnlinux.DeleteNMConnection(conn.nmConn); perr != nil { + return + } + } + + // All following errors are harmless and can be ignored. + if conn.table > 0 { + vpnlinux.DeleteRoutingTable(conn.table) + } + if conn.outline != nil { + conn.outline.Close() + } vpnlinux.CloseTUNDevice(conn.tun) + + slog.Info("VPN connection closed") return nil } diff --git a/client/go/outline/electron/vpn_windows.go b/client/go/outline/electron/vpn_windows.go index 30de9e133c..b4c4cf2e9e 100644 --- a/client/go/outline/electron/vpn_windows.go +++ b/client/go/outline/electron/vpn_windows.go @@ -20,6 +20,11 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) +type VPNConnection struct { + Status string `json:"status"` + RouteUDP bool `json:"routeUDP"` +} + func establishVPN(context.Context, *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { return nil, &platerrors.PlatformError{ Code: platerrors.InternalError, diff --git a/client/go/outline/electron/vpnlinux/iprule_linux.go b/client/go/outline/electron/vpnlinux/iprule_linux.go index dad43f776d..c3a14ff4b3 100644 --- a/client/go/outline/electron/vpnlinux/iprule_linux.go +++ b/client/go/outline/electron/vpnlinux/iprule_linux.go @@ -21,14 +21,16 @@ import ( "github.com/vishvananda/netlink" ) -func AddIPRule(tableId int, fwMark uint32) (*netlink.Rule, *platerrors.PlatformError) { +func AddIPRules(tableId int, fwMark uint32) (*netlink.Rule, *platerrors.PlatformError) { rule := netlink.NewRule() - // rule.Priority = 1357 + rule.Priority = 23456 + rule.Family = netlink.FAMILY_ALL rule.Table = tableId rule.Mark = fwMark - // rule.Invert = true + rule.Invert = true if err := netlink.RuleAdd(rule); err != nil { + slog.Error("failed to add IP rule", "rule", rule, "err", err) return nil, &platerrors.PlatformError{ Code: platerrors.SetupSystemVPNFailed, Message: "failed to add ip rule to Outline routing table", @@ -40,10 +42,11 @@ func AddIPRule(tableId int, fwMark uint32) (*netlink.Rule, *platerrors.PlatformE return rule, nil } -func DelIPRule(rule *netlink.Rule) *platerrors.PlatformError { +func DeleteIPRules(rule *netlink.Rule) *platerrors.PlatformError { if rule == nil { return nil } + if err := netlink.RuleDel(rule); err != nil { slog.Error("failed to remove IP rule", "rule", rule, "err", err) return &platerrors.PlatformError{ diff --git a/client/go/outline/electron/vpnlinux/nmconn_linux.go b/client/go/outline/electron/vpnlinux/nmconn_linux.go index fe8758c713..8391b51c08 100644 --- a/client/go/outline/electron/vpnlinux/nmconn_linux.go +++ b/client/go/outline/electron/vpnlinux/nmconn_linux.go @@ -15,18 +15,110 @@ package vpnlinux import ( - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Wifx/gonetworkmanager/v2" + "encoding/binary" + "log/slog" + "net" + "time" + + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + gonm "github.com/Wifx/gonetworkmanager/v2" ) -func ConfigureNMConnection() *platerrors.PlatformError { - _, err := gonetworkmanager.NewSettings() +const nmLogPrefix = "[NetworkManager] " + +func ConfigureNMConnection(tun *TUNDevice, dns net.IP) (gonm.Connection, *perrs.PlatformError) { + nm, err := gonm.NewNetworkManager() if err != nil { - return &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to connect to NetworkManager", - Cause: platerrors.ToPlatformError(err), - } + return nil, nmErr("failed to connect", err) + } + slog.Debug(nmLogPrefix + "connected") + + dev, err := nm.GetDeviceByIpIface(tun.name) + if err != nil { + return nil, nmErr("failed to find TUN device", err, "tun", tun.name) + } + slog.Debug(nmLogPrefix+"found TUN device", "tun", tun.name, "dev", dev.GetPath()) + + aconn, perr := waitForActiveConnection(dev) + if perr != nil { + return nil, perr + } + + conn, err := aconn.GetPropertyConnection() + if err != nil { + return nil, nmErr("failed to get the underlying connection", err, "conn", aconn.GetPath()) + } + slog.Debug(nmLogPrefix+"got the underlying connection", "conn", aconn.GetPath(), "setting", conn.GetPath()) + + props, err := conn.GetSettings() + if err != nil { + return nil, nmErr("failed to read setting values", err, "setting", conn.GetPath()) + } + slog.Debug(nmLogPrefix+"got all setting values", "setting", conn.GetPath()) + + purgeLegacyIPv6Props(props) + configureDNSProps(props, dns) + + if err := conn.Update(props); err != nil { + return nil, nmErr("failed to update connection setting", err, "setting", conn.GetPath()) + } + + slog.Info(nmLogPrefix+"successfully configured NetworkManager connection", "conn", conn.GetPath()) + return conn, nil +} + +func DeleteNMConnection(conn gonm.Connection) *perrs.PlatformError { + err := conn.Delete() + if err != nil { + return nmErr("failed to delete connection setting", err, "setting", conn.GetPath()) } return nil } + +var waitIntervals = []time.Duration{ + 20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 150 * time.Millisecond, + 200 * time.Millisecond, 500 * time.Millisecond, 1 * time.Second, 2 * time.Second, 4 * time.Second} + +// waitForActiveConnection waits for an gonm.ActiveConnection to be ready. +func waitForActiveConnection(dev gonm.Device) (gonm.ActiveConnection, *perrs.PlatformError) { + for _, interval := range waitIntervals { + slog.Debug(nmLogPrefix + "waiting for active connection ...") + time.Sleep(interval) + conn, err := dev.GetPropertyActiveConnection() + if err == nil && conn != nil { + slog.Debug(nmLogPrefix+"active connection identified", "dev", dev.GetPath(), "conn", conn.GetPath()) + return conn, nil + } + } + return nil, nmErr("TUN device connection was not ready in time", nil, "dev", dev.GetPath()) +} + +func purgeLegacyIPv6Props(props gonm.ConnectionSettings) { + if ipv6Props, ok := props["ipv6"]; ok { + delete(ipv6Props, "addresses") + delete(ipv6Props, "routes") + } +} + +func configureDNSProps(props gonm.ConnectionSettings, dns4 net.IP) { + dnsIPv4 := binary.NativeEndian.Uint32(dns4.To4()) + props["ipv4"]["dns"] = []uint32{dnsIPv4} +} + +func nmErr(msg string, cause error, params ...any) *perrs.PlatformError { + logParams := append(params, "err", cause) + slog.Error(nmLogPrefix+msg, logParams...) + + details := perrs.ErrorDetails{} + for i := 1; i < len(params); i += 2 { + if key, ok := params[i-1].(string); ok { + details[key] = params[i] + } + } + return &perrs.PlatformError{ + Code: perrs.SetupSystemVPNFailed, + Message: "NetworkManager: " + msg, + Details: details, + Cause: perrs.ToPlatformError(cause), + } +} diff --git a/client/go/outline/electron/vpnlinux/routing_linux.go b/client/go/outline/electron/vpnlinux/routing_linux.go index 4e8e658bd8..9c974f3ee3 100644 --- a/client/go/outline/electron/vpnlinux/routing_linux.go +++ b/client/go/outline/electron/vpnlinux/routing_linux.go @@ -15,23 +15,74 @@ package vpnlinux import ( + "errors" + "log/slog" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/vishvananda/netlink" ) -func ConfigureRoutingTable(tunName string, tableId int) *platerrors.PlatformError { - tun, err := netlink.LinkByName(tunName) - if err != nil { +func ConfigureRoutingTable(tun *TUNDevice, tableId int) *platerrors.PlatformError { + // Make sure delete previous routing entries + DeleteRoutingTable(tableId) + + // ip route add default via "<10.0.85.5>" dev "outline-tun0" table "13579" + r := &netlink.Route{ + LinkIndex: tun.link.Attrs().Index, + Table: tableId, + Gw: tun.ip.IP, + //Dst: tun.ip.IPNet, + //Src: tun.ip.IP, + Scope: netlink.SCOPE_LINK, + } + if err := netlink.RouteAdd(r); err != nil { + slog.Error("failed to add routing entry", "table", tableId, "route", r, "err", err) return &platerrors.PlatformError{ Code: platerrors.SetupSystemVPNFailed, - Message: "failed to locate TUN device from the netlink API", + Message: "failed to add routing entries to routing table", + Details: platerrors.ErrorDetails{"table": tableId}, Cause: platerrors.ToPlatformError(err), } } + slog.Info("successfully added routing entry", "table", tableId, "route", r) - _ = netlink.Route{ - LinkIndex: tun.Attrs().Index, - Table: tableId, + return nil +} + +func DeleteRoutingTable(tableId int) *platerrors.PlatformError { + filter := &netlink.Route{Table: tableId} + routes, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE) + if err != nil { + slog.Warn("failed to list routing entries", "table", tableId) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "failed to list routing entries in routing table", + Details: platerrors.ErrorDetails{"table": tableId}, + Cause: platerrors.ToPlatformError(err), + } + } + + nDel := 0 + var errs error + for _, r := range routes { + if err := netlink.RouteDel(&r); err == nil { + slog.Debug("successfully deleted routing entry", "table", tableId, "route", r) + nDel++ + } else { + slog.Warn("failed to delete routing entry", "table", tableId, "route", r) + errs = errors.Join(errs, err) + } } + if errs != nil { + slog.Warn("not able to delete all routing entries", "table", tableId, "err", errs) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "not able to delete all routing entries in routing table", + Details: platerrors.ErrorDetails{"table": tableId}, + Cause: platerrors.ToPlatformError(errs), + } + } + + slog.Info("successfully deleted all routing entries", "table", tableId, "n", nDel) return nil } diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go index a69f892185..50bea096b8 100644 --- a/client/go/outline/electron/vpnlinux/tun_linux.go +++ b/client/go/outline/electron/vpnlinux/tun_linux.go @@ -19,10 +19,30 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/songgao/water" + "github.com/vishvananda/netlink" ) -func ConfigureTUNDevice(name string) (_ *water.Interface, perr *platerrors.PlatformError) { - tun, err := water.New(water.Config{ +type TUNDevice struct { + File *water.Interface + + name string + ip *netlink.Addr + link netlink.Link +} + +func ConfigureTUNDevice(name, ip string) (_ *TUNDevice, perr *platerrors.PlatformError) { + // Make sure the previous TUN device is deleted + CloseTUNDevice(&TUNDevice{name: name}) + + var err error + tun := &TUNDevice{} + defer func() { + if perr != nil { + CloseTUNDevice(tun) + } + }() + + tun.File, err = water.New(water.Config{ DeviceType: water.TUN, PlatformSpecificParams: water.PlatformSpecificParams{ Name: name, @@ -30,37 +50,94 @@ func ConfigureTUNDevice(name string) (_ *water.Interface, perr *platerrors.Platf }, }) if err != nil { + slog.Error("failed to create TUN device", "name", name, "err", err) return nil, &platerrors.PlatformError{ Code: platerrors.SetupSystemVPNFailed, - Message: "failed to open the TUN device", + Message: "failed to create the TUN device", Details: platerrors.ErrorDetails{"name": name}, Cause: platerrors.ToPlatformError(err), } } - defer func() { - if perr != nil { - CloseTUNDevice(tun) + tun.name = tun.File.Name() + slog.Info("successfully created TUN device", "name", tun.name) + + if tun.link, err = netlink.LinkByName(tun.name); err != nil { + slog.Error("failed to find the newly created TUN device", "name", tun.name, "err", err) + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to find the created TUN device", + Details: platerrors.ErrorDetails{"name": tun.name}, + Cause: platerrors.ToPlatformError(err), } - }() + } + + ipCidr := ip + "/32" + addr, err := netlink.ParseAddr(ipCidr) + if err != nil { + return nil, &platerrors.PlatformError{ + Code: platerrors.IllegalConfig, + Message: "VPN local IP address is not valid", + Details: platerrors.ErrorDetails{"ip": ipCidr}, + Cause: platerrors.ToPlatformError(err), + } + } + if err = netlink.AddrReplace(tun.link, addr); err != nil { + slog.Error("failed to assign IP to the TUN device", "name", tun.name, "ip", ipCidr, "err", err) + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to assign IP to the TUN device", + Details: platerrors.ErrorDetails{"name": tun.name, "ip": ipCidr}, + Cause: platerrors.ToPlatformError(err), + } + } + tun.ip = addr + slog.Info("successfully assigned IP address to the TUN device", "name", tun.name, "ip", tun.ip) + + if err = netlink.LinkSetUp(tun.link); err != nil { + slog.Error("failed to bring up the TUN device", "name", tun.name, "err", err) + return nil, &platerrors.PlatformError{ + Code: platerrors.SetupSystemVPNFailed, + Message: "failed to bring up the TUN device", + Details: platerrors.ErrorDetails{"name": tun.name}, + Cause: platerrors.ToPlatformError(err), + } + } + slog.Info("successfully brought up the TUN device", "name", tun.name) - slog.Info("successfully configured TUN device", "name", tun.Name()) return tun, nil } -func CloseTUNDevice(tun *water.Interface) *platerrors.PlatformError { +func CloseTUNDevice(tun *TUNDevice) *platerrors.PlatformError { if tun == nil { return nil } - if err := tun.Close(); err != nil { - slog.Error("failed to close TUN device", "name", tun.Name(), "err", err) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "failed to close the TUN device", - Details: platerrors.ErrorDetails{"name": tun.Name()}, - Cause: platerrors.ToPlatformError(err), + if tun.name != "" && tun.link == nil { + tun.link, _ = netlink.LinkByName(tun.name) + } + if tun.File != nil { + if err := tun.File.Close(); err != nil { + slog.Error("failed to close TUN file", "name", tun.name, "err", err) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "failed to close the TUN device", + Details: platerrors.ErrorDetails{"name": tun.name}, + Cause: platerrors.ToPlatformError(err), + } + } + slog.Info("successfully closed TUN file", "name", tun.name) + } + if tun.link != nil { + if err := netlink.LinkDel(tun.link); err != nil { + slog.Warn("delete TUN device", "name", tun.name, "err", err) + return &platerrors.PlatformError{ + Code: platerrors.DisconnectSystemVPNFailed, + Message: "failed to delete the TUN device", + Details: platerrors.ErrorDetails{"name": tun.name}, + Cause: platerrors.ToPlatformError(err), + } } + slog.Info("successfully deleted TUN device", "name", tun.name) } - slog.Info("successfully closed TUN device", "name", tun.Name()) return nil } From 3bc0c433aa1c88b831e1df7e63bb3227900a5f2d Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 2 Dec 2024 20:23:06 -0500 Subject: [PATCH 05/27] refactor to OOP based design --- client/electron/index.ts | 11 +- client/electron/vpn_service.ts | 28 +++- client/go/outline/client.go | 5 +- client/go/outline/electron/outline_device.go | 24 +-- client/go/outline/electron/vpn.go | 104 ++++++++---- client/go/outline/electron/vpn_linux.go | 149 ++++++++++++------ client/go/outline/electron/vpn_windows.go | 16 +- client/go/outline/electron/vpnlinux/errors.go | 53 +++++++ .../outline/electron/vpnlinux/iprule_linux.go | 61 ------- .../outline/electron/vpnlinux/nmconn_linux.go | 106 +++++++------ .../electron/vpnlinux/routing_linux.go | 114 ++++++++------ .../go/outline/electron/vpnlinux/tun_linux.go | 103 +++++------- client/go/outline/platerrors/error_code.go | 3 + 13 files changed, 439 insertions(+), 338 deletions(-) create mode 100644 client/go/outline/electron/vpnlinux/errors.go delete mode 100644 client/go/outline/electron/vpnlinux/iprule_linux.go diff --git a/client/electron/index.ts b/client/electron/index.ts index f69611823e..eb88a9ed61 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -38,7 +38,7 @@ import {GoApiName, invokeGoApi} from './go_plugin'; import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; -import {closeVpn, establishVpn} from './vpn_service'; +import {closeVpn, establishVpn, onVpnStatusChanged} from './vpn_service'; import {VpnTunnel} from './vpn_tunnel'; import * as config from '../src/www/app/outline_server_repository/config'; import { @@ -369,9 +369,12 @@ async function createVpnTunnel( // Invoked by both the start-proxying event handler and auto-connect. async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { - if (IS_LINUX) { + if (IS_LINUX && !process.env.APPIMAGE) { + onVpnStatusChanged((id, status) => { + setUiTunnelStatus(status, id); + console.info('VPN Status Changed: ', id, status); + }); await establishVpn(request); - setUiTunnelStatus(TunnelStatus.CONNECTED, request.id); return; } @@ -408,7 +411,7 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { // Invoked by both the stop-proxying event and quit handler. async function stopVpn() { - if (IS_LINUX) { + if (IS_LINUX && !process.env.APPIMAGE) { await Promise.all([closeVpn(), tearDownAutoLaunch()]); return; } diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 7e68d2ef98..ae48d79aaf 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -13,24 +13,35 @@ // limitations under the License. import {invokeGoApi} from './go_plugin'; -import {StartRequestJson} from '../src/www/app/outline_server_repository/vpn'; +import { + StartRequestJson, + TunnelStatus, +} from '../src/www/app/outline_server_repository/vpn'; interface VpnConfig { + id: string; interfaceName: string; ipAddress: string; dnsServers: string[]; routingTableId: number; + routingPriority: number; protectionMark: number; transport: string; } +let currentRequestId: string | undefined = undefined; + export async function establishVpn(request: StartRequestJson) { + currentRequestId = request.id; + statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); const config: VpnConfig = { + id: currentRequestId, interfaceName: 'outline-tun0', ipAddress: '10.0.85.5', dnsServers: ['8.8.4.4'], - routingTableId: 13579, - protectionMark: 24680, + routingTableId: 7113, + routingPriority: 28958, + protectionMark: 0x711e, transport: JSON.stringify(request.config.transport), }; const connectionJson = await invokeGoApi( @@ -38,8 +49,19 @@ export async function establishVpn(request: StartRequestJson) { JSON.stringify(config) ); console.info(JSON.parse(connectionJson)); + statusCb?.(currentRequestId, TunnelStatus.CONNECTED); } export async function closeVpn(): Promise { + statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTING); await invokeGoApi('CloseVPN', ''); + statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTED); +} + +export type VpnStatusCallback = (id: string, status: TunnelStatus) => void; + +let statusCb: VpnStatusCallback | undefined = undefined; + +export function onVpnStatusChanged(cb: VpnStatusCallback): void { + statusCb = cb; } diff --git a/client/go/outline/client.go b/client/go/outline/client.go index bb496fce46..e8cf3e8a5c 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,13 +42,16 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - client, err := NewClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{}) + client, err := NewClientWithBaseDialers(transportConfig, DefaultBaseTCPDialer(), DefaultBaseUDPDialer()) return &NewClientResult{ Client: client, Error: platerrors.ToPlatformError(err), } } +func DefaultBaseTCPDialer() net.Dialer { return net.Dialer{KeepAlive: -1} } +func DefaultBaseUDPDialer() net.Dialer { return net.Dialer{} } + func NewClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) { conf, err := parseConfigFromJSON(transportConfig) if err != nil { diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go index df11de0a2f..c027222043 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/electron/outline_device.go @@ -16,7 +16,6 @@ package main import ( "log/slog" - "net" "syscall" "github.com/Jigsaw-Code/outline-apps/client/go/outline" @@ -37,22 +36,15 @@ func configureOutlineDevice(transportConfig string, sockmark int) (*outlineDevic var err error dev := &outlineDevice{} - tcpDialer := net.Dialer{ - Control: func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - slog.Debug("Setting SO_MARK to TCP connection", "SO_MARK", sockmark) - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) - }) - }, - } - udpDialer := net.Dialer{ - Control: func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - slog.Debug("Setting SO_MARK to UDP connection", "SO_MARK", sockmark) - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) - }) - }, + controlFWMark := func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) + }) } + tcpDialer := outline.DefaultBaseTCPDialer() + udpDialer := outline.DefaultBaseUDPDialer() + tcpDialer.Control = controlFWMark + udpDialer.Control = controlFWMark c, err := outline.NewClientWithBaseDialers(transportConfig, tcpDialer, udpDialer) if err != nil { diff --git a/client/go/outline/electron/vpn.go b/client/go/outline/electron/vpn.go index 162cb8c6bb..69170b8538 100644 --- a/client/go/outline/electron/vpn.go +++ b/client/go/outline/electron/vpn.go @@ -15,64 +15,114 @@ package main import ( - "context" "encoding/json" + "log/slog" + "sync" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -type VPNConfig struct { +type vpnConfigJSON struct { + ID string `json:"id"` InterfaceName string `json:"interfaceName"` IPAddress string `json:"ipAddress"` DNSServers []string `json:"dnsServers"` RoutingTableId int `json:"routingTableId"` + RoutingPriority int `json:"routingPriority"` ProtectionMark uint32 `json:"protectionMark"` TransportConfig string `json:"transport"` } -var conn *VPNConnection +type vpnConnectionJSON struct { + ID string `json:"id"` + Status string `json:"status"` + RouteUDP *bool `json:"routeUDP"` +} + +type VPNStatus string + +const ( + VPNConnected VPNStatus = "Connected" + VPNDisconnected VPNStatus = "Disconnected" + VPNConnecting VPNStatus = "Connecting" + VPNDisconnecting VPNStatus = "Disconnecting" +) + +type VPNConnection interface { + ID() string + Status() VPNStatus + RouteUDP() *bool -func EstablishVPN(configStr string) (_ string, perr *platerrors.PlatformError) { - var config VPNConfig - err := json.Unmarshal([]byte(configStr), &config) + Establish() *perrs.PlatformError + Close() *perrs.PlatformError +} + +var mu sync.Mutex +var conn VPNConnection + +func EstablishVPN(configStr string) (_ string, perr *perrs.PlatformError) { + var conf vpnConfigJSON + err := json.Unmarshal([]byte(configStr), &conf) if err != nil { - return "", &platerrors.PlatformError{ - Code: platerrors.IllegalConfig, - Message: "illegal VPN config format", - Cause: platerrors.ToPlatformError(err), + return "", &perrs.PlatformError{ + Code: perrs.IllegalConfig, + Message: "invalid VPN config format", + Cause: perrs.ToPlatformError(err), } } - if conn != nil { - CloseVPN() + var c VPNConnection + if c, perr = newVPNConnection(&conf); perr != nil { + return } - if conn, perr = establishVPN(context.TODO(), &config); perr != nil { + if perr = atomicReplaceVPNConn(c); perr != nil { + c.Close() return } - - if conn == nil { - return "", &platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "unexpected nil VPN connection", - } + slog.Debug("Establishing VPN connection ...", "id", c.ID()) + if perr = c.Establish(); perr != nil { + // No need to call c.Close() cuz it's tracked in the global conn already + return } - connJson, err := json.Marshal(conn) + slog.Info("VPN connection established", "id", c.ID()) + + connJson, err := json.Marshal(vpnConnectionJSON{c.ID(), string(c.Status()), c.RouteUDP()}) if err != nil { - return "", &platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "failed to marshal VPN connection", - Cause: platerrors.ToPlatformError(err), + return "", &perrs.PlatformError{ + Code: perrs.InternalError, + Message: "failed to return VPN connection as JSON", + Cause: perrs.ToPlatformError(err), } } return string(connJson), nil } -func CloseVPN() (perr *platerrors.PlatformError) { +func CloseVPN() *perrs.PlatformError { + mu.Lock() + defer mu.Unlock() + return closeVPNNoLock() +} + +func atomicReplaceVPNConn(newConn VPNConnection) *perrs.PlatformError { + mu.Lock() + defer mu.Unlock() + slog.Debug("Adding VPN Connection ...", "id", newConn.ID()) + if err := closeVPNNoLock(); err != nil { + return err + } + conn = newConn + slog.Info("VPN Connection added", "id", newConn.ID()) + return nil +} + +func closeVPNNoLock() (perr *perrs.PlatformError) { if conn == nil { return nil } - if perr = closeVPNConn(conn); perr == nil { + slog.Debug("Closing existing VPN Connection ...", "id", conn.ID()) + if perr = conn.Close(); perr == nil { conn = nil + slog.Info("VPN Connection closed", "id", conn.ID()) } return } diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index 629559496e..fb472166fc 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -22,94 +22,139 @@ import ( "sync" "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Wifx/gonetworkmanager/v2" - "github.com/vishvananda/netlink" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -type VPNConnection struct { - Status string `json:"status"` - RouteUDP bool `json:"routeUDP"` +type linuxVPNConn struct { + id string + status VPNStatus + routeUDP *bool - ctx context.Context `json:"-"` - wg sync.WaitGroup `json:"-"` + transport string + fwmark uint32 + tunName string + tunCidr *net.IPNet + dnsIP net.IP + rtID, rtPri int - outline *outlineDevice `json:"-"` + ctx context.Context + cancel context.CancelFunc + wgEst, wgCopy sync.WaitGroup - tun *vpnlinux.TUNDevice `json:"-"` - nmConn gonetworkmanager.Connection `json:"-"` - table int `json:"-"` - rule *netlink.Rule `json:"-"` + outline *outlineDevice + + tun *vpnlinux.TUNDevice + nmConn *vpnlinux.NMConnection + route *vpnlinux.RoutingRule } -func establishVPN(ctx context.Context, conf *VPNConfig) (_ *VPNConnection, perr *platerrors.PlatformError) { - slog.Debug("establishing VPN connection ...", "config", conf) - conn := &VPNConnection{ctx: ctx} - defer func() { - if perr != nil { - closeVPNConn(conn) - } - }() +var _ VPNConnection = (*linuxVPNConn)(nil) + +func newVPNConnection(conf *vpnConfigJSON) (*linuxVPNConn, *perrs.PlatformError) { + c := &linuxVPNConn{ + id: conf.ID, + status: VPNDisconnected, + transport: conf.TransportConfig, + tunName: conf.InterfaceName, + fwmark: conf.ProtectionMark, + rtID: conf.RoutingTableId, + rtPri: conf.RoutingPriority, + } - // Create Outline socket and protect it - if conn.outline, perr = configureOutlineDevice(conf.TransportConfig, int(conf.ProtectionMark)); perr != nil { - return + if c.transport == "" { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "transport config is required") + } + if c.tunName == "" { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN interface name is required") + } + if conf.IPAddress == "" { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN IP is required") } + _, cidr, err := net.ParseCIDR(conf.IPAddress + "/32") + if c.tunCidr = cidr; err != nil { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN IP is invalid") + } + if c.dnsIP = net.ParseIP(conf.DNSServers[0]); c.dnsIP == nil { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "DNS IP is invalid") + } + if c.rtID < 0 { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "Routing Table ID must be greater than 0") + } + if c.rtPri < 0 { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "Routing Priority must be greater than 0") + } + + c.ctx, c.cancel = context.WithCancel(context.Background()) + return c, nil +} - if conn.tun, perr = vpnlinux.ConfigureTUNDevice(conf.InterfaceName, conf.IPAddress); perr != nil { - return nil, perr +func (c *linuxVPNConn) ID() string { return c.id } +func (c *linuxVPNConn) Status() VPNStatus { return c.status } +func (c *linuxVPNConn) RouteUDP() *bool { return c.routeUDP } + +func (c *linuxVPNConn) Establish() (perr *perrs.PlatformError) { + c.wgEst.Add(1) + defer c.wgEst.Done() + if c.ctx.Err() != nil { + return &perrs.PlatformError{Code: perrs.OperationCanceled} + } + + if c.outline, perr = configureOutlineDevice(c.transport, int(c.fwmark)); perr != nil { + return } - if conn.nmConn, perr = vpnlinux.ConfigureNMConnection(conn.tun, net.ParseIP(conf.DNSServers[0])); perr != nil { + + if c.tun, perr = vpnlinux.NewTUNDevice(c.tunName, c.tunCidr); perr != nil { return } - if perr = vpnlinux.ConfigureRoutingTable(conn.tun, conf.RoutingTableId); perr != nil { + if c.nmConn, perr = vpnlinux.NewNMConnection(c.tun, c.dnsIP); perr != nil { return } - conn.table = conf.RoutingTableId - if conn.rule, perr = vpnlinux.AddIPRules(conf.RoutingTableId, conf.ProtectionMark); perr != nil { + if c.route, perr = vpnlinux.NewRoutingRule(c.tun, c.rtID, c.rtPri, c.fwmark); perr != nil { return } + c.wgCopy.Add(2) go func() { + defer c.wgCopy.Done() slog.Debug("Copying traffic from TUN Device -> OutlineDevice...") - n, err := io.Copy(conn.outline, conn.tun.File) + n, err := io.Copy(c.outline, c.tun.File) slog.Debug("TUN Device -> OutlineDevice done", "n", n, "err", err) }() go func() { + defer c.wgCopy.Done() slog.Debug("Copying traffic from OutlineDevice -> TUN Device...") - n, err := io.Copy(conn.tun.File, conn.outline) + n, err := io.Copy(c.tun.File, c.outline) slog.Debug("OutlineDevice -> TUN Device done", "n", n, "err", err) }() - slog.Info("VPN connection established", "conn", conn) - return conn, nil + return nil } -func closeVPNConn(conn *VPNConnection) (perr *platerrors.PlatformError) { - if conn == nil { +func (c *linuxVPNConn) Close() (perr *perrs.PlatformError) { + if c == nil { return nil } + c.cancel() + c.wgEst.Wait() - if conn.rule != nil { - if perr = vpnlinux.DeleteIPRules(conn.rule); perr != nil { - return - } - } - if conn.nmConn != nil { - if perr = vpnlinux.DeleteNMConnection(conn.nmConn); perr != nil { - return - } + if c.route != nil { + perr = c.route.Close() } // All following errors are harmless and can be ignored. - if conn.table > 0 { - vpnlinux.DeleteRoutingTable(conn.table) + if err := c.nmConn.Close(); err == nil { + c.nmConn = nil } - if conn.outline != nil { - conn.outline.Close() + if c.outline != nil { + if err := c.outline.Close(); err == nil { + c.outline = nil + } + } + if err := c.tun.Close(); err == nil { + c.tun = nil } - vpnlinux.CloseTUNDevice(conn.tun) - slog.Info("VPN connection closed") - return nil + // Wait for traffic copy go routines to finish + c.wgCopy.Wait() + return } diff --git a/client/go/outline/electron/vpn_windows.go b/client/go/outline/electron/vpn_windows.go index b4c4cf2e9e..812aafc562 100644 --- a/client/go/outline/electron/vpn_windows.go +++ b/client/go/outline/electron/vpn_windows.go @@ -15,26 +15,12 @@ package main import ( - "context" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -type VPNConnection struct { - Status string `json:"status"` - RouteUDP bool `json:"routeUDP"` -} - -func establishVPN(context.Context, *VPNConfig) (*VPNConnection, *platerrors.PlatformError) { +func newVPNConnection(conf *vpnConfigJSON) (VPNConnection, *platerrors.PlatformError) { return nil, &platerrors.PlatformError{ Code: platerrors.InternalError, Message: "not implemented yet", } } - -func closeVPNConn(*VPNConnection) *platerrors.PlatformError { - return &platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "not implemented yet", - } -} diff --git a/client/go/outline/electron/vpnlinux/errors.go b/client/go/outline/electron/vpnlinux/errors.go new file mode 100644 index 0000000000..62c89f8049 --- /dev/null +++ b/client/go/outline/electron/vpnlinux/errors.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpnlinux + +import ( + "log/slog" + + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +const ( + ioLogPfx = "[IO] " + nlLogPfx = "[NetLink] " + nmLogPfx = "[NetworkManager] " +) + +func errSetupVPN(pfx, msg string, cause error, params ...any) *perrs.PlatformError { + return errPlatError(perrs.SetupSystemVPNFailed, pfx+msg, cause, params...) +} + +func errCloseVPN(pfx, msg string, cause error, params ...any) *perrs.PlatformError { + return errPlatError(perrs.DisconnectSystemVPNFailed, pfx+msg, cause, params...) +} + +func errPlatError(code perrs.ErrorCode, msg string, cause error, params ...any) *perrs.PlatformError { + logParams := append(params, "err", cause) + slog.Error(msg, logParams...) + + details := perrs.ErrorDetails{} + for i := 1; i < len(params); i += 2 { + if key, ok := params[i-1].(string); ok { + details[key] = params[i] + } + } + return &perrs.PlatformError{ + Code: code, + Message: msg, + Details: details, + Cause: perrs.ToPlatformError(cause), + } +} diff --git a/client/go/outline/electron/vpnlinux/iprule_linux.go b/client/go/outline/electron/vpnlinux/iprule_linux.go deleted file mode 100644 index c3a14ff4b3..0000000000 --- a/client/go/outline/electron/vpnlinux/iprule_linux.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package vpnlinux - -import ( - "log/slog" - - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/vishvananda/netlink" -) - -func AddIPRules(tableId int, fwMark uint32) (*netlink.Rule, *platerrors.PlatformError) { - rule := netlink.NewRule() - rule.Priority = 23456 - rule.Family = netlink.FAMILY_ALL - rule.Table = tableId - rule.Mark = fwMark - rule.Invert = true - - if err := netlink.RuleAdd(rule); err != nil { - slog.Error("failed to add IP rule", "rule", rule, "err", err) - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to add ip rule to Outline routing table", - Cause: platerrors.ToPlatformError(err), - } - } - - slog.Info("successfully added IP rule", "rule", rule) - return rule, nil -} - -func DeleteIPRules(rule *netlink.Rule) *platerrors.PlatformError { - if rule == nil { - return nil - } - - if err := netlink.RuleDel(rule); err != nil { - slog.Error("failed to remove IP rule", "rule", rule, "err", err) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "failed to remove the ip rule to Outline routing table", - Cause: platerrors.ToPlatformError(err), - } - } - - slog.Info("successfully removed IP rule", "rule", rule) - return nil -} diff --git a/client/go/outline/electron/vpnlinux/nmconn_linux.go b/client/go/outline/electron/vpnlinux/nmconn_linux.go index 8391b51c08..4cf2a9f355 100644 --- a/client/go/outline/electron/vpnlinux/nmconn_linux.go +++ b/client/go/outline/electron/vpnlinux/nmconn_linux.go @@ -24,54 +24,78 @@ import ( gonm "github.com/Wifx/gonetworkmanager/v2" ) -const nmLogPrefix = "[NetworkManager] " +type NMConnection struct { + nm gonm.NetworkManager + ac gonm.ActiveConnection + c gonm.Connection +} -func ConfigureNMConnection(tun *TUNDevice, dns net.IP) (gonm.Connection, *perrs.PlatformError) { - nm, err := gonm.NewNetworkManager() - if err != nil { - return nil, nmErr("failed to connect", err) +func NewNMConnection(tun *TUNDevice, dns net.IP) (_ *NMConnection, perr *perrs.PlatformError) { + c := &NMConnection{} + defer func() { + if perr != nil { + c.Close() + } + }() + + var err error + if c.nm, err = gonm.NewNetworkManager(); err != nil { + return nil, errSetupVPN(nmLogPfx, "failed to connect", err) } - slog.Debug(nmLogPrefix + "connected") + slog.Debug(nmLogPfx + "connected") - dev, err := nm.GetDeviceByIpIface(tun.name) + dev, err := c.nm.GetDeviceByIpIface(tun.name) if err != nil { - return nil, nmErr("failed to find TUN device", err, "tun", tun.name) + return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", tun.name) } - slog.Debug(nmLogPrefix+"found TUN device", "tun", tun.name, "dev", dev.GetPath()) + slog.Debug(nmLogPfx+"located TUN device", "tun", tun.name, "dev", dev.GetPath()) - aconn, perr := waitForActiveConnection(dev) - if perr != nil { + if c.ac, perr = waitForActiveConnection(dev); perr != nil { return nil, perr } - conn, err := aconn.GetPropertyConnection() - if err != nil { - return nil, nmErr("failed to get the underlying connection", err, "conn", aconn.GetPath()) + if c.c, err = c.ac.GetPropertyConnection(); err != nil { + return nil, errSetupVPN(nmLogPfx, "failed to get the underlying connection", err, "conn", c.ac.GetPath()) } - slog.Debug(nmLogPrefix+"got the underlying connection", "conn", aconn.GetPath(), "setting", conn.GetPath()) + slog.Debug(nmLogPfx+"found the underlying connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) - props, err := conn.GetSettings() + props, err := c.c.GetSettings() if err != nil { - return nil, nmErr("failed to read setting values", err, "setting", conn.GetPath()) + return nil, errSetupVPN(nmLogPfx, "failed to read setting values", err, "setting", c.c.GetPath()) } - slog.Debug(nmLogPrefix+"got all setting values", "setting", conn.GetPath()) + slog.Debug(nmLogPfx+"retrieved all setting values", "setting", c.c.GetPath()) purgeLegacyIPv6Props(props) configureDNSProps(props, dns) - if err := conn.Update(props); err != nil { - return nil, nmErr("failed to update connection setting", err, "setting", conn.GetPath()) + if err := c.c.Update(props); err != nil { + return nil, errSetupVPN(nmLogPfx, "failed to update connection setting", err, "setting", c.c.GetPath()) } + slog.Debug(nmLogPfx+"saved all new setting values", "setting", c.c.GetPath()) - slog.Info(nmLogPrefix+"successfully configured NetworkManager connection", "conn", conn.GetPath()) - return conn, nil + slog.Info("successfully configured NetworkManager connection", "conn", c.ac.GetPath()) + return c, nil } -func DeleteNMConnection(conn gonm.Connection) *perrs.PlatformError { - err := conn.Delete() - if err != nil { - return nmErr("failed to delete connection setting", err, "setting", conn.GetPath()) +func (c *NMConnection) Close() *perrs.PlatformError { + if c == nil || c.nm == nil { + return nil + } + + if c.ac != nil { + if err := c.nm.DeactivateConnection(c.ac); err != nil { + slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", c.ac.GetPath()) + } + slog.Debug(nmLogPfx+"deactivated connection", "conn", c.ac.GetPath()) + } + if c.c != nil { + if err := c.c.Delete(); err != nil { + return errCloseVPN(nmLogPfx, "failed to delete connection setting", err, "setting", c.c.GetPath()) + } + slog.Debug(nmLogPfx+"connection setting deleted", "setting", c.c.GetPath()) } + + slog.Info("cleaned up NetworkManager connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) return nil } @@ -82,18 +106,19 @@ var waitIntervals = []time.Duration{ // waitForActiveConnection waits for an gonm.ActiveConnection to be ready. func waitForActiveConnection(dev gonm.Device) (gonm.ActiveConnection, *perrs.PlatformError) { for _, interval := range waitIntervals { - slog.Debug(nmLogPrefix + "waiting for active connection ...") + slog.Debug(nmLogPfx + "waiting for active connection ...") time.Sleep(interval) conn, err := dev.GetPropertyActiveConnection() if err == nil && conn != nil { - slog.Debug(nmLogPrefix+"active connection identified", "dev", dev.GetPath(), "conn", conn.GetPath()) + slog.Debug(nmLogPfx+"active connection identified", "dev", dev.GetPath(), "conn", conn.GetPath()) return conn, nil } } - return nil, nmErr("TUN device connection was not ready in time", nil, "dev", dev.GetPath()) + return nil, errSetupVPN(nmLogPfx, "TUN device connection was not ready in time", nil, "dev", dev.GetPath()) } func purgeLegacyIPv6Props(props gonm.ConnectionSettings) { + // These props are legacy IPv6 settings that won't be accepted by the NetworkManager D-Bus API if ipv6Props, ok := props["ipv6"]; ok { delete(ipv6Props, "addresses") delete(ipv6Props, "routes") @@ -101,24 +126,15 @@ func purgeLegacyIPv6Props(props gonm.ConnectionSettings) { } func configureDNSProps(props gonm.ConnectionSettings, dns4 net.IP) { + // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. dnsIPv4 := binary.NativeEndian.Uint32(dns4.To4()) props["ipv4"]["dns"] = []uint32{dnsIPv4} -} -func nmErr(msg string, cause error, params ...any) *perrs.PlatformError { - logParams := append(params, "err", cause) - slog.Error(nmLogPrefix+msg, logParams...) + // A lower value has a higher priority. + // Negative values will exclude other configurations with a greater value. + props["ipv4"]["dns-priority"] = -99 - details := perrs.ErrorDetails{} - for i := 1; i < len(params); i += 2 { - if key, ok := params[i-1].(string); ok { - details[key] = params[i] - } - } - return &perrs.PlatformError{ - Code: perrs.SetupSystemVPNFailed, - Message: "NetworkManager: " + msg, - Details: details, - Cause: perrs.ToPlatformError(cause), - } + // routing domain to exclude all other DNS resolvers + // https://manpages.ubuntu.com/manpages/jammy/man5/resolved.conf.5.html + props["ipv4"]["dns-search"] = []string{"~."} } diff --git a/client/go/outline/electron/vpnlinux/routing_linux.go b/client/go/outline/electron/vpnlinux/routing_linux.go index 9c974f3ee3..7bbfda0a3f 100644 --- a/client/go/outline/electron/vpnlinux/routing_linux.go +++ b/client/go/outline/electron/vpnlinux/routing_linux.go @@ -18,71 +18,91 @@ import ( "errors" "log/slog" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/vishvananda/netlink" ) -func ConfigureRoutingTable(tun *TUNDevice, tableId int) *platerrors.PlatformError { +type RoutingRule struct { + table int + rule *netlink.Rule +} + +func NewRoutingRule(tun *TUNDevice, table, priority int, fwmark uint32) (_ *RoutingRule, perr *perrs.PlatformError) { + r := &RoutingRule{table: table} + defer func() { + if perr != nil { + r.Close() + } + }() + // Make sure delete previous routing entries - DeleteRoutingTable(tableId) + r.Close() - // ip route add default via "<10.0.85.5>" dev "outline-tun0" table "13579" - r := &netlink.Route{ + // ip route add default via "<10.0.85.5>" dev "outline-tun0" table "113" + rt := &netlink.Route{ LinkIndex: tun.link.Attrs().Index, - Table: tableId, + Table: r.table, Gw: tun.ip.IP, - //Dst: tun.ip.IPNet, - //Src: tun.ip.IP, - Scope: netlink.SCOPE_LINK, + Scope: netlink.SCOPE_LINK, } - if err := netlink.RouteAdd(r); err != nil { - slog.Error("failed to add routing entry", "table", tableId, "route", r, "err", err) - return &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to add routing entries to routing table", - Details: platerrors.ErrorDetails{"table": tableId}, - Cause: platerrors.ToPlatformError(err), - } + if err := netlink.RouteAdd(rt); err != nil { + return nil, errSetupVPN(nlLogPfx, "failed to add routing entry", err, "table", r.table, "route", rt) } - slog.Info("successfully added routing entry", "table", tableId, "route", r) + slog.Debug(nlLogPfx+"routing entry added", "table", r.table, "route", rt) - return nil + // ip rule add not fwmark "0x711E" table "113" priority "456" + r.rule = netlink.NewRule() + r.rule.Priority = priority + r.rule.Family = netlink.FAMILY_ALL + r.rule.Table = r.table + r.rule.Mark = fwmark + r.rule.Invert = true + if err := netlink.RuleAdd(r.rule); err != nil { + return nil, errSetupVPN(nlLogPfx, "failed to add IP rule", err, "rule", r.rule) + } + slog.Debug(nlLogPfx+"IP rule added", "rule", r.rule) + + slog.Info("successfully configured routing", "table", r.table, "rule", r.rule) + return r, nil } -func DeleteRoutingTable(tableId int) *platerrors.PlatformError { - filter := &netlink.Route{Table: tableId} - routes, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE) - if err != nil { - slog.Warn("failed to list routing entries", "table", tableId) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "failed to list routing entries in routing table", - Details: platerrors.ErrorDetails{"table": tableId}, - Cause: platerrors.ToPlatformError(err), - } +func (r *RoutingRule) Close() *perrs.PlatformError { + if r == nil { + return nil } - nDel := 0 - var errs error - for _, r := range routes { - if err := netlink.RouteDel(&r); err == nil { - slog.Debug("successfully deleted routing entry", "table", tableId, "route", r) - nDel++ - } else { - slog.Warn("failed to delete routing entry", "table", tableId, "route", r) - errs = errors.Join(errs, err) + if r.rule != nil { + if err := netlink.RuleDel(r.rule); err != nil { + return errCloseVPN(nlLogPfx, "failed to delete IP rule", err, "rule", r.rule) } + slog.Debug(nlLogPfx+"deleted IP rule", "rule", r.rule) + r.rule = nil } - if errs != nil { - slog.Warn("not able to delete all routing entries", "table", tableId, "err", errs) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "not able to delete all routing entries in routing table", - Details: platerrors.ErrorDetails{"table": tableId}, - Cause: platerrors.ToPlatformError(errs), + + if r.table > 0 { + filter := &netlink.Route{Table: r.table} + rts, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE) + if err != nil { + return errCloseVPN(nlLogPfx, "failed to list routing entries", err, "table", r.table) + } + + nDel := 0 + var errs error + for _, rt := range rts { + if err := netlink.RouteDel(&rt); err == nil { + slog.Debug("successfully deleted routing entry", "table", r.table, "route", rt) + nDel++ + } else { + slog.Warn("failed to delete routing entry", "table", r.table, "route", rt, "err", err) + errs = errors.Join(errs, err) + } + } + if errs != nil { + return errCloseVPN(nlLogPfx, "failed to delete all routig entries", errs, "table", r.table) } + slog.Debug(nlLogPfx+"deleted all routing entries", "table", r.table, "n", nDel) } - slog.Info("successfully deleted all routing entries", "table", tableId, "n", nDel) + slog.Info("successfully cleaned up routing", "table", r.table) return nil } diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go index 50bea096b8..bc899d1905 100644 --- a/client/go/outline/electron/vpnlinux/tun_linux.go +++ b/client/go/outline/electron/vpnlinux/tun_linux.go @@ -15,9 +15,12 @@ package vpnlinux import ( + "errors" "log/slog" + "net" + "syscall" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/songgao/water" "github.com/vishvananda/netlink" ) @@ -30,15 +33,17 @@ type TUNDevice struct { link netlink.Link } -func ConfigureTUNDevice(name, ip string) (_ *TUNDevice, perr *platerrors.PlatformError) { +func NewTUNDevice(name string, ipCidr *net.IPNet) (_ *TUNDevice, perr *perrs.PlatformError) { + var err error + tun := &TUNDevice{name: name} + // Make sure the previous TUN device is deleted - CloseTUNDevice(&TUNDevice{name: name}) + tun.Close() - var err error - tun := &TUNDevice{} + // Make sure we don't leak any resources if anything goes wrong defer func() { if perr != nil { - CloseTUNDevice(tun) + tun.Close() } }() @@ -50,94 +55,58 @@ func ConfigureTUNDevice(name, ip string) (_ *TUNDevice, perr *platerrors.Platfor }, }) if err != nil { - slog.Error("failed to create TUN device", "name", name, "err", err) - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to create the TUN device", - Details: platerrors.ErrorDetails{"name": name}, - Cause: platerrors.ToPlatformError(err), - } + return nil, errSetupVPN(ioLogPfx, "failed to create TUN file", err, "name", name) } tun.name = tun.File.Name() - slog.Info("successfully created TUN device", "name", tun.name) + slog.Debug(ioLogPfx+"TUN file created", "name", tun.name) if tun.link, err = netlink.LinkByName(tun.name); err != nil { - slog.Error("failed to find the newly created TUN device", "name", tun.name, "err", err) - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to find the created TUN device", - Details: platerrors.ErrorDetails{"name": tun.name}, - Cause: platerrors.ToPlatformError(err), - } + return nil, errSetupVPN(nlLogPfx, "failed to find the new TUN device", err, "name", tun.name) } + slog.Debug(nlLogPfx+"TUN device found", "name", tun.name) - ipCidr := ip + "/32" - addr, err := netlink.ParseAddr(ipCidr) - if err != nil { - return nil, &platerrors.PlatformError{ - Code: platerrors.IllegalConfig, - Message: "VPN local IP address is not valid", - Details: platerrors.ErrorDetails{"ip": ipCidr}, - Cause: platerrors.ToPlatformError(err), - } - } - if err = netlink.AddrReplace(tun.link, addr); err != nil { - slog.Error("failed to assign IP to the TUN device", "name", tun.name, "ip", ipCidr, "err", err) - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to assign IP to the TUN device", - Details: platerrors.ErrorDetails{"name": tun.name, "ip": ipCidr}, - Cause: platerrors.ToPlatformError(err), - } + tun.ip = &netlink.Addr{IPNet: ipCidr} + if err = netlink.AddrReplace(tun.link, &netlink.Addr{IPNet: ipCidr}); err != nil { + return nil, errSetupVPN(nlLogPfx, "failed to assign IP to TUN device", + err, "name", tun.name, "ip", ipCidr.String()) } - tun.ip = addr - slog.Info("successfully assigned IP address to the TUN device", "name", tun.name, "ip", tun.ip) + slog.Debug(nlLogPfx+"assigned IP to TUN device", "name", tun.name, "ip", tun.ip) if err = netlink.LinkSetUp(tun.link); err != nil { - slog.Error("failed to bring up the TUN device", "name", tun.name, "err", err) - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupSystemVPNFailed, - Message: "failed to bring up the TUN device", - Details: platerrors.ErrorDetails{"name": tun.name}, - Cause: platerrors.ToPlatformError(err), - } + return nil, errSetupVPN(nlLogPfx, "failed to bring up TUN device", err, "name", tun.name) } - slog.Info("successfully brought up the TUN device", "name", tun.name) + slog.Debug(nlLogPfx+"brought up TUN device", "name", tun.name) + slog.Info("successfully configured Outline TUN device", "name", tun.name) return tun, nil } -func CloseTUNDevice(tun *TUNDevice) *platerrors.PlatformError { +func (tun *TUNDevice) Close() *perrs.PlatformError { if tun == nil { return nil } if tun.name != "" && tun.link == nil { tun.link, _ = netlink.LinkByName(tun.name) } + if tun.File != nil { if err := tun.File.Close(); err != nil { - slog.Error("failed to close TUN file", "name", tun.name, "err", err) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "failed to close the TUN device", - Details: platerrors.ErrorDetails{"name": tun.name}, - Cause: platerrors.ToPlatformError(err), - } + return errCloseVPN(ioLogPfx, "failed to close TUN file", err, "name", tun.name) } - slog.Info("successfully closed TUN file", "name", tun.name) + slog.Debug(ioLogPfx+"closed TUN file", "name", tun.name) + tun.File = nil } + if tun.link != nil { - if err := netlink.LinkDel(tun.link); err != nil { - slog.Warn("delete TUN device", "name", tun.name, "err", err) - return &platerrors.PlatformError{ - Code: platerrors.DisconnectSystemVPNFailed, - Message: "failed to delete the TUN device", - Details: platerrors.ErrorDetails{"name": tun.name}, - Cause: platerrors.ToPlatformError(err), - } + // Typically the previous Close call should delete the TUN device + if err := netlink.LinkDel(tun.link); err != nil && errors.Is(err, syscall.ENODEV) { + return errCloseVPN(nlLogPfx, "failed to delete TUN device", err, "name", tun.name) } - slog.Info("successfully deleted TUN device", "name", tun.name) + slog.Debug(nlLogPfx+"deleted TUN device", "name", tun.name) + tun.link = nil } + slog.Info("cleaned up Outline TUN device", "name", tun.name) + tun.name = "" return nil } diff --git a/client/go/outline/platerrors/error_code.go b/client/go/outline/platerrors/error_code.go index 4175ab9d08..72ba6f80c5 100644 --- a/client/go/outline/platerrors/error_code.go +++ b/client/go/outline/platerrors/error_code.go @@ -31,6 +31,9 @@ type ErrorCode = string const ( // InternalError represents a general internal service error. InternalError ErrorCode = "ERR_INTERNAL_ERROR" + + // OperationCanceled means that user canceled the long running operation. + OperationCanceled ErrorCode = "ERR_OPERATION_CANCELED_BY_USER" ) ////////// From 07c15d2db1004753e1d593ae538ad72a294a6e5e Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Tue, 3 Dec 2024 15:42:11 -0500 Subject: [PATCH 06/27] refactor outlineDevice object --- client/go/outline/electron/outline_device.go | 91 ++++++++++--------- .../outline/electron/outline_device_linux.go | 35 +++++++ .../electron/outline_device_windows.go | 23 +++++ client/go/outline/electron/vpn_linux.go | 53 +++++++---- client/go/outline/electron/vpn_windows.go | 5 +- .../outline/electron/vpnlinux/socket_linux.go | 28 ++++++ 6 files changed, 171 insertions(+), 64 deletions(-) create mode 100644 client/go/outline/electron/outline_device_linux.go create mode 100644 client/go/outline/electron/outline_device_windows.go create mode 100644 client/go/outline/electron/vpnlinux/socket_linux.go diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go index c027222043..284061b060 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/electron/outline_device.go @@ -16,10 +16,9 @@ package main import ( "log/slog" - "syscall" "github.com/Jigsaw-Code/outline-apps/client/go/outline" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/Jigsaw-Code/outline-sdk/network" "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" @@ -27,64 +26,72 @@ import ( type outlineDevice struct { network.IPDevice - network.DelegatePacketProxy + + c *outline.Client + pkt network.DelegatePacketProxy remote, fallback network.PacketProxy } -func configureOutlineDevice(transportConfig string, sockmark int) (*outlineDevice, *platerrors.PlatformError) { +func (d *outlineDevice) Connect() (perr *perrs.PlatformError) { var err error - dev := &outlineDevice{} - controlFWMark := func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, sockmark) - }) + d.remote, err = network.NewPacketProxyFromPacketListener(d.c.PacketListener) + if err != nil { + return errSetupHandler("failed to create datagram handler", err) } - tcpDialer := outline.DefaultBaseTCPDialer() - udpDialer := outline.DefaultBaseUDPDialer() - tcpDialer.Control = controlFWMark - udpDialer.Control = controlFWMark + slog.Debug("[OutlineNetDev] remote UDP handler created") - c, err := outline.NewClientWithBaseDialers(transportConfig, tcpDialer, udpDialer) - if err != nil { - return nil, platerrors.ToPlatformError(err) + if d.fallback, err = dnstruncate.NewPacketProxy(); err != nil { + return errSetupHandler("failed to create datagram handler for DNS fallback", err) } + slog.Debug("[OutlineNetDev] local DNS-fallback UDP handler created") - dev.remote, err = network.NewPacketProxyFromPacketListener(c.PacketListener) - if err != nil { - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupTrafficHandlerFailed, - Message: "failed to create datagram handler", - Cause: platerrors.ToPlatformError(err), - } + if perr = d.RefreshConnectivity(); perr != nil { + return } - if dev.fallback, err = dnstruncate.NewPacketProxy(); err != nil { - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupTrafficHandlerFailed, - Message: "failed to create datagram handler for DNS fallback", - Cause: platerrors.ToPlatformError(err), - } + d.IPDevice, err = lwip2transport.ConfigureDevice(d.c.StreamDialer, d.pkt) + if err != nil { + return errSetupHandler("failed to configure Outline network stack", err) } + slog.Debug("[OutlineNetDev] lwIP network stack configured") - if dev.DelegatePacketProxy, err = network.NewDelegatePacketProxy(dev.remote); err != nil { - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupTrafficHandlerFailed, - Message: "failed to combine datagram handlers", - Cause: platerrors.ToPlatformError(err), + slog.Info("successfully connected Outline network device") + return nil +} + +func (d *outlineDevice) Close() (err error) { + if d.IPDevice != nil { + if err = d.IPDevice.Close(); err == nil { + d.IPDevice = nil } } + slog.Info("successfully closed Outline network device") + return +} - dev.IPDevice, err = lwip2transport.ConfigureDevice(c.StreamDialer, dev) - if err != nil { - return nil, &platerrors.PlatformError{ - Code: platerrors.SetupTrafficHandlerFailed, - Message: "failed to configure network stack", - Cause: platerrors.ToPlatformError(err), +func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { + var err error + proxy := d.remote + if d.pkt == nil { + if d.pkt, err = network.NewDelegatePacketProxy(proxy); err != nil { + return errSetupHandler("failed to create combined datagram handler", err) + } + } else { + if err = d.pkt.SetProxy(proxy); err != nil { + return errSetupHandler("failed to update combined datagram handler", err) } } + slog.Debug("[OutlineNetDev] UDP handler refreshed") + return nil +} - slog.Info("successfully configured outline device") - return dev, nil +func errSetupHandler(msg string, cause error) *perrs.PlatformError { + slog.Error("[OutlineNetDev] "+msg, "err", cause) + return &perrs.PlatformError{ + Code: perrs.SetupTrafficHandlerFailed, + Message: msg, + Cause: perrs.ToPlatformError(cause), + } } diff --git a/client/go/outline/electron/outline_device_linux.go b/client/go/outline/electron/outline_device_linux.go new file mode 100644 index 0000000000..45648d9781 --- /dev/null +++ b/client/go/outline/electron/outline_device_linux.go @@ -0,0 +1,35 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/Jigsaw-Code/outline-apps/client/go/outline" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +func newOutlineDevice(transport string, fwmark uint32) (_ *outlineDevice, perr *perrs.PlatformError) { + tcpDialer := outline.DefaultBaseTCPDialer() + udpDialer := outline.DefaultBaseUDPDialer() + vpnlinux.ProtectSocket(&tcpDialer, fwmark) + vpnlinux.ProtectSocket(&udpDialer, fwmark) + + var err error + dev := &outlineDevice{} + if dev.c, err = outline.NewClientWithBaseDialers(transport, tcpDialer, udpDialer); err != nil { + return nil, perrs.ToPlatformError(err) + } + return dev, nil +} diff --git a/client/go/outline/electron/outline_device_windows.go b/client/go/outline/electron/outline_device_windows.go new file mode 100644 index 0000000000..7424a10b6a --- /dev/null +++ b/client/go/outline/electron/outline_device_windows.go @@ -0,0 +1,23 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +func newOutlineDevice(transport string, fwmark uint32) (_ *outlineDevice, perr *perrs.PlatformError) { + return nil, perrs.NewPlatformError(perrs.InternalError, "not implemented yet") +} diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index fb472166fc..ae7677ce8d 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -30,7 +30,6 @@ type linuxVPNConn struct { status VPNStatus routeUDP *bool - transport string fwmark uint32 tunName string tunCidr *net.IPNet @@ -50,20 +49,16 @@ type linuxVPNConn struct { var _ VPNConnection = (*linuxVPNConn)(nil) -func newVPNConnection(conf *vpnConfigJSON) (*linuxVPNConn, *perrs.PlatformError) { +func newVPNConnection(conf *vpnConfigJSON) (_ *linuxVPNConn, perr *perrs.PlatformError) { c := &linuxVPNConn{ - id: conf.ID, - status: VPNDisconnected, - transport: conf.TransportConfig, - tunName: conf.InterfaceName, - fwmark: conf.ProtectionMark, - rtID: conf.RoutingTableId, - rtPri: conf.RoutingPriority, + id: conf.ID, + status: VPNDisconnected, + tunName: conf.InterfaceName, + fwmark: conf.ProtectionMark, + rtID: conf.RoutingTableId, + rtPri: conf.RoutingPriority, } - if c.transport == "" { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "transport config is required") - } if c.tunName == "" { return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN interface name is required") } @@ -83,6 +78,12 @@ func newVPNConnection(conf *vpnConfigJSON) (*linuxVPNConn, *perrs.PlatformError) if c.rtPri < 0 { return nil, perrs.NewPlatformError(perrs.IllegalConfig, "Routing Priority must be greater than 0") } + if conf.TransportConfig == "" { + return nil, perrs.NewPlatformError(perrs.IllegalConfig, "transport config is required") + } + if c.outline, perr = newOutlineDevice(conf.TransportConfig, c.fwmark); perr != nil { + return + } c.ctx, c.cancel = context.WithCancel(context.Background()) return c, nil @@ -99,7 +100,16 @@ func (c *linuxVPNConn) Establish() (perr *perrs.PlatformError) { return &perrs.PlatformError{Code: perrs.OperationCanceled} } - if c.outline, perr = configureOutlineDevice(c.transport, int(c.fwmark)); perr != nil { + c.status = VPNConnecting + defer func() { + if perr == nil { + c.status = VPNConnected + } else { + c.status = VPNDisconnected + } + }() + + if perr = c.outline.Connect(); perr != nil { return } @@ -134,6 +144,17 @@ func (c *linuxVPNConn) Close() (perr *perrs.PlatformError) { if c == nil { return nil } + + prevStatus := c.status + c.status = VPNDisconnecting + defer func() { + if perr == nil { + c.status = VPNDisconnected + } else { + c.status = prevStatus + } + }() + c.cancel() c.wgEst.Wait() @@ -145,14 +166,10 @@ func (c *linuxVPNConn) Close() (perr *perrs.PlatformError) { if err := c.nmConn.Close(); err == nil { c.nmConn = nil } - if c.outline != nil { - if err := c.outline.Close(); err == nil { - c.outline = nil - } - } if err := c.tun.Close(); err == nil { c.tun = nil } + c.outline.Close() // Wait for traffic copy go routines to finish c.wgCopy.Wait() diff --git a/client/go/outline/electron/vpn_windows.go b/client/go/outline/electron/vpn_windows.go index 812aafc562..71eec11563 100644 --- a/client/go/outline/electron/vpn_windows.go +++ b/client/go/outline/electron/vpn_windows.go @@ -19,8 +19,5 @@ import ( ) func newVPNConnection(conf *vpnConfigJSON) (VPNConnection, *platerrors.PlatformError) { - return nil, &platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "not implemented yet", - } + return nil, platerrors.NewPlatformError(platerrors.InternalError, "not implemented yet") } diff --git a/client/go/outline/electron/vpnlinux/socket_linux.go b/client/go/outline/electron/vpnlinux/socket_linux.go new file mode 100644 index 0000000000..5c70863a00 --- /dev/null +++ b/client/go/outline/electron/vpnlinux/socket_linux.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpnlinux + +import ( + "net" + "syscall" +) + +func ProtectSocket(d *net.Dialer, fwmark uint32) { + d.Control = func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) + }) + } +} From b97cca8473344afc2c329d6f9cd82183271398fa Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Tue, 3 Dec 2024 17:55:19 -0500 Subject: [PATCH 07/27] add allowed license "BSD-2-Clause" --- .github/workflows/license.yml | 2 +- client/electron/electron-builder.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index 1f5d44d26f..342e2b36cf 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -44,4 +44,4 @@ jobs: - name: Check Go dependency tree licenses # We allow only "notice" type of licenses. - run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./... + run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./... diff --git a/client/electron/electron-builder.json b/client/electron/electron-builder.json index a259fa0748..4b8f65638c 100644 --- a/client/electron/electron-builder.json +++ b/client/electron/electron-builder.json @@ -36,6 +36,11 @@ "icon": "client/electron/icons/png", "maintainer": "Jigsaw LLC", "target": [{ + "arch": [ + "x64" + ], + "target": "AppImage" + }, { "arch": "x64", "target": "deb" }] From 89029fb0ad2323ff7dc3202154c6098b5d7c7cf9 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Wed, 4 Dec 2024 13:59:12 -0500 Subject: [PATCH 08/27] initial connectivity check --- client/go/outline/electron/outline_device.go | 29 +++++++++++++++++--- client/go/outline/electron/vpn_linux.go | 7 ++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go index 284061b060..360f2bd85e 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/electron/outline_device.go @@ -27,8 +27,9 @@ import ( type outlineDevice struct { network.IPDevice - c *outline.Client - pkt network.DelegatePacketProxy + c *outline.Client + pkt network.DelegatePacketProxy + routeUDP *bool remote, fallback network.PacketProxy } @@ -73,7 +74,22 @@ func (d *outlineDevice) Close() (err error) { func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { var err error - proxy := d.remote + result := outline.CheckTCPAndUDPConnectivity(d.c) + if result.TCPError != nil { + return result.TCPError + } + + var proxy network.PacketProxy + canHandleUDP := false + if result.UDPError != nil { + slog.Warn("[OutlineNetDev] server cannot handle UDP traffic", "err", result.UDPError) + proxy = d.fallback + } else { + slog.Info("[OutlineNetDev] server can handle UDP traffic") + proxy = d.remote + canHandleUDP = true + } + if d.pkt == nil { if d.pkt, err = network.NewDelegatePacketProxy(proxy); err != nil { return errSetupHandler("failed to create combined datagram handler", err) @@ -83,10 +99,15 @@ func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { return errSetupHandler("failed to update combined datagram handler", err) } } - slog.Debug("[OutlineNetDev] UDP handler refreshed") + d.routeUDP = &canHandleUDP + slog.Info("[OutlineNetDev] UDP handler refreshed", "routeUDP", canHandleUDP) return nil } +func (d *outlineDevice) RouteUDP() *bool { + return d.routeUDP +} + func errSetupHandler(msg string, cause error) *perrs.PlatformError { slog.Error("[OutlineNetDev] "+msg, "err", cause) return &perrs.PlatformError{ diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index ae7677ce8d..fb5915b389 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -26,9 +26,8 @@ import ( ) type linuxVPNConn struct { - id string - status VPNStatus - routeUDP *bool + id string + status VPNStatus fwmark uint32 tunName string @@ -91,7 +90,7 @@ func newVPNConnection(conf *vpnConfigJSON) (_ *linuxVPNConn, perr *perrs.Platfor func (c *linuxVPNConn) ID() string { return c.id } func (c *linuxVPNConn) Status() VPNStatus { return c.status } -func (c *linuxVPNConn) RouteUDP() *bool { return c.routeUDP } +func (c *linuxVPNConn) RouteUDP() *bool { return c.outline.RouteUDP() } func (c *linuxVPNConn) Establish() (perr *perrs.PlatformError) { c.wgEst.Add(1) From d91d38870389e29993d2bfb06ce1723191747e44 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Wed, 4 Dec 2024 17:57:12 -0500 Subject: [PATCH 09/27] refine some log messages --- client/go/outline/electron/outline_device.go | 8 +++----- client/go/outline/electron/vpn.go | 12 ++++++------ client/go/outline/electron/vpn_linux.go | 8 ++++---- .../go/outline/electron/vpnlinux/nmconn_linux.go | 4 ++-- .../outline/electron/vpnlinux/routing_linux.go | 16 ++++++++-------- client/go/outline/electron/vpnlinux/tun_linux.go | 8 +++----- 6 files changed, 26 insertions(+), 30 deletions(-) diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/electron/outline_device.go index 360f2bd85e..6c18ae252d 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/electron/outline_device.go @@ -58,17 +58,15 @@ func (d *outlineDevice) Connect() (perr *perrs.PlatformError) { } slog.Debug("[OutlineNetDev] lwIP network stack configured") - slog.Info("successfully connected Outline network device") + slog.Info("[VPN] successfully connected to Outline server") return nil } func (d *outlineDevice) Close() (err error) { if d.IPDevice != nil { - if err = d.IPDevice.Close(); err == nil { - d.IPDevice = nil - } + err = d.IPDevice.Close() } - slog.Info("successfully closed Outline network device") + slog.Info("[VPN] successfully disconnected from Outline server") return } diff --git a/client/go/outline/electron/vpn.go b/client/go/outline/electron/vpn.go index 69170b8538..6e5a70d100 100644 --- a/client/go/outline/electron/vpn.go +++ b/client/go/outline/electron/vpn.go @@ -79,12 +79,12 @@ func EstablishVPN(configStr string) (_ string, perr *perrs.PlatformError) { c.Close() return } - slog.Debug("Establishing VPN connection ...", "id", c.ID()) + slog.Debug("[VPN] Establishing VPN connection ...", "id", c.ID()) if perr = c.Establish(); perr != nil { // No need to call c.Close() cuz it's tracked in the global conn already return } - slog.Info("VPN connection established", "id", c.ID()) + slog.Info("[VPN] VPN connection established", "id", c.ID()) connJson, err := json.Marshal(vpnConnectionJSON{c.ID(), string(c.Status()), c.RouteUDP()}) if err != nil { @@ -106,12 +106,12 @@ func CloseVPN() *perrs.PlatformError { func atomicReplaceVPNConn(newConn VPNConnection) *perrs.PlatformError { mu.Lock() defer mu.Unlock() - slog.Debug("Adding VPN Connection ...", "id", newConn.ID()) + slog.Debug("[VPN] Creating VPN Connection ...", "id", newConn.ID()) if err := closeVPNNoLock(); err != nil { return err } conn = newConn - slog.Info("VPN Connection added", "id", newConn.ID()) + slog.Info("[VPN] VPN Connection created", "id", newConn.ID()) return nil } @@ -119,10 +119,10 @@ func closeVPNNoLock() (perr *perrs.PlatformError) { if conn == nil { return nil } - slog.Debug("Closing existing VPN Connection ...", "id", conn.ID()) + slog.Debug("[VPN] Closing existing VPN Connection ...", "id", conn.ID()) if perr = conn.Close(); perr == nil { + slog.Info("[VPN] VPN Connection closed", "id", conn.ID()) conn = nil - slog.Info("VPN Connection closed", "id", conn.ID()) } return } diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go index fb5915b389..b1fd0a79c9 100644 --- a/client/go/outline/electron/vpn_linux.go +++ b/client/go/outline/electron/vpn_linux.go @@ -125,15 +125,15 @@ func (c *linuxVPNConn) Establish() (perr *perrs.PlatformError) { c.wgCopy.Add(2) go func() { defer c.wgCopy.Done() - slog.Debug("Copying traffic from TUN Device -> OutlineDevice...") + slog.Debug("[IO] Copying traffic from TUN Device -> OutlineDevice...") n, err := io.Copy(c.outline, c.tun.File) - slog.Debug("TUN Device -> OutlineDevice done", "n", n, "err", err) + slog.Debug("[IO] TUN Device -> OutlineDevice done", "n", n, "err", err) }() go func() { defer c.wgCopy.Done() - slog.Debug("Copying traffic from OutlineDevice -> TUN Device...") + slog.Debug("[IO] Copying traffic from OutlineDevice -> TUN Device...") n, err := io.Copy(c.tun.File, c.outline) - slog.Debug("OutlineDevice -> TUN Device done", "n", n, "err", err) + slog.Debug("[IO] OutlineDevice -> TUN Device done", "n", n, "err", err) }() return nil diff --git a/client/go/outline/electron/vpnlinux/nmconn_linux.go b/client/go/outline/electron/vpnlinux/nmconn_linux.go index 4cf2a9f355..a4659e3390 100644 --- a/client/go/outline/electron/vpnlinux/nmconn_linux.go +++ b/client/go/outline/electron/vpnlinux/nmconn_linux.go @@ -73,7 +73,7 @@ func NewNMConnection(tun *TUNDevice, dns net.IP) (_ *NMConnection, perr *perrs.P } slog.Debug(nmLogPfx+"saved all new setting values", "setting", c.c.GetPath()) - slog.Info("successfully configured NetworkManager connection", "conn", c.ac.GetPath()) + slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) return c, nil } @@ -95,7 +95,7 @@ func (c *NMConnection) Close() *perrs.PlatformError { slog.Debug(nmLogPfx+"connection setting deleted", "setting", c.c.GetPath()) } - slog.Info("cleaned up NetworkManager connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) + slog.Info(nmLogPfx+"cleaned up NetworkManager connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) return nil } diff --git a/client/go/outline/electron/vpnlinux/routing_linux.go b/client/go/outline/electron/vpnlinux/routing_linux.go index 7bbfda0a3f..63e66bfe86 100644 --- a/client/go/outline/electron/vpnlinux/routing_linux.go +++ b/client/go/outline/electron/vpnlinux/routing_linux.go @@ -48,7 +48,7 @@ func NewRoutingRule(tun *TUNDevice, table, priority int, fwmark uint32) (_ *Rout if err := netlink.RouteAdd(rt); err != nil { return nil, errSetupVPN(nlLogPfx, "failed to add routing entry", err, "table", r.table, "route", rt) } - slog.Debug(nlLogPfx+"routing entry added", "table", r.table, "route", rt) + slog.Info(nlLogPfx+"routing entry added", "table", r.table, "route", rt) // ip rule add not fwmark "0x711E" table "113" priority "456" r.rule = netlink.NewRule() @@ -60,9 +60,8 @@ func NewRoutingRule(tun *TUNDevice, table, priority int, fwmark uint32) (_ *Rout if err := netlink.RuleAdd(r.rule); err != nil { return nil, errSetupVPN(nlLogPfx, "failed to add IP rule", err, "rule", r.rule) } - slog.Debug(nlLogPfx+"IP rule added", "rule", r.rule) + slog.Info(nlLogPfx+"IP rule added", "rule", r.rule) - slog.Info("successfully configured routing", "table", r.table, "rule", r.rule) return r, nil } @@ -75,7 +74,7 @@ func (r *RoutingRule) Close() *perrs.PlatformError { if err := netlink.RuleDel(r.rule); err != nil { return errCloseVPN(nlLogPfx, "failed to delete IP rule", err, "rule", r.rule) } - slog.Debug(nlLogPfx+"deleted IP rule", "rule", r.rule) + slog.Info(nlLogPfx+"deleted IP rule", "rule", r.rule) r.rule = nil } @@ -90,19 +89,20 @@ func (r *RoutingRule) Close() *perrs.PlatformError { var errs error for _, rt := range rts { if err := netlink.RouteDel(&rt); err == nil { - slog.Debug("successfully deleted routing entry", "table", r.table, "route", rt) + slog.Debug(nlLogPfx+"successfully deleted routing entry", "table", r.table, "route", rt) nDel++ } else { - slog.Warn("failed to delete routing entry", "table", r.table, "route", rt, "err", err) + slog.Warn(nlLogPfx+"failed to delete routing entry", "table", r.table, "route", rt, "err", err) errs = errors.Join(errs, err) } } if errs != nil { return errCloseVPN(nlLogPfx, "failed to delete all routig entries", errs, "table", r.table) } - slog.Debug(nlLogPfx+"deleted all routing entries", "table", r.table, "n", nDel) + if nDel > 0 { + slog.Info(nlLogPfx+"deleted all routing entries", "table", r.table, "n", nDel) + } } - slog.Info("successfully cleaned up routing", "table", r.table) return nil } diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go index bc899d1905..585cc1d3c2 100644 --- a/client/go/outline/electron/vpnlinux/tun_linux.go +++ b/client/go/outline/electron/vpnlinux/tun_linux.go @@ -77,7 +77,7 @@ func NewTUNDevice(name string, ipCidr *net.IPNet) (_ *TUNDevice, perr *perrs.Pla } slog.Debug(nlLogPfx+"brought up TUN device", "name", tun.name) - slog.Info("successfully configured Outline TUN device", "name", tun.name) + slog.Info("[TUN] successfully configured TUN device", "name", tun.name) return tun, nil } @@ -93,20 +93,18 @@ func (tun *TUNDevice) Close() *perrs.PlatformError { if err := tun.File.Close(); err != nil { return errCloseVPN(ioLogPfx, "failed to close TUN file", err, "name", tun.name) } - slog.Debug(ioLogPfx+"closed TUN file", "name", tun.name) + slog.Info(ioLogPfx+"closed TUN device", "name", tun.name) tun.File = nil } if tun.link != nil { // Typically the previous Close call should delete the TUN device - if err := netlink.LinkDel(tun.link); err != nil && errors.Is(err, syscall.ENODEV) { + if err := netlink.LinkDel(tun.link); err != nil && !errors.Is(err, syscall.ENODEV) { return errCloseVPN(nlLogPfx, "failed to delete TUN device", err, "name", tun.name) } slog.Debug(nlLogPfx+"deleted TUN device", "name", tun.name) tun.link = nil } - slog.Info("cleaned up Outline TUN device", "name", tun.name) - tun.name = "" return nil } From 407e2a183210c38e5476a75e91a54a9c1df048ac Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Wed, 4 Dec 2024 18:43:51 -0500 Subject: [PATCH 10/27] resolve conflicts. --- client/electron/vpn_service.ts | 12 ++++-------- client/go/outline/electron/go_plugin.go | 8 ++++---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index ae48d79aaf..b6cf7a8721 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {invokeGoApi} from './go_plugin'; +import {invokeMethod} from './go_plugin'; import { StartRequestJson, TunnelStatus, @@ -38,23 +38,19 @@ export async function establishVpn(request: StartRequestJson) { id: currentRequestId, interfaceName: 'outline-tun0', ipAddress: '10.0.85.5', - dnsServers: ['8.8.4.4'], + dnsServers: ['9.9.9.9'], routingTableId: 7113, routingPriority: 28958, protectionMark: 0x711e, transport: JSON.stringify(request.config.transport), }; - const connectionJson = await invokeGoApi( - 'EstablishVPN', - JSON.stringify(config) - ); - console.info(JSON.parse(connectionJson)); + await invokeMethod('EstablishVPN', JSON.stringify(config)); statusCb?.(currentRequestId, TunnelStatus.CONNECTED); } export async function closeVpn(): Promise { statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTING); - await invokeGoApi('CloseVPN', ''); + await invokeMethod('CloseVPN', ''); statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTED); } diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index d8c613f237..443a2a5f8d 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -45,8 +45,8 @@ import ( const ( // EstablishVPNAPI initiates a VPN connection and directs all network traffic through Outline. // - // - Input: a JSON string of [VPNConfig]. - // - Output: a JSON string of [VPNConnection]. + // - Input: a JSON string of vpnConfigJSON. + // - Output: a JSON string of vpnConnectionJSON. EstablishVPNAPI = "EstablishVPN" // CloseVPNAPI closes an existing VPN connection and restores network traffic to the default @@ -71,14 +71,14 @@ func InvokeMethod(method *C.char, input *C.char) C.InvokeMethodResult { // Electron specific APIs case EstablishVPNAPI: res, err := EstablishVPN(C.GoString(input)) - return C.InvokeGoAPIResult{ + return C.InvokeMethodResult{ Output: newCGoString(res), ErrorJson: marshalCGoErrorJson(err), } case CloseVPNAPI: err := CloseVPN() - return C.InvokeGoAPIResult{ErrorJson: marshalCGoErrorJson(err)} + return C.InvokeMethodResult{ErrorJson: marshalCGoErrorJson(err)} // Common APIs default: From 0dede891fb238273992b51765249c1869e4f01ef Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 17:32:14 -0500 Subject: [PATCH 11/27] Clean up VPN routing to leverage network manager --- client/electron/electron-builder.json | 2 +- client/electron/vpn_service.ts | 25 ++- client/go/outline/client.go | 8 +- .../{electron/outline_device.go => device.go} | 68 ++++--- client/go/outline/device_linux.go | 38 ++++ ...ne_device_windows.go => device_windows.go} | 10 +- client/go/outline/electron/go_plugin.go | 69 ++----- .../outline/electron/outline_device_linux.go | 35 ---- client/go/outline/electron/vpn_linux.go | 176 ------------------ .../outline/electron/vpnlinux/nmconn_linux.go | 140 -------------- .../electron/vpnlinux/routing_linux.go | 108 ----------- .../go/outline/electron/vpnlinux/tun_linux.go | 110 ----------- client/go/outline/method_channel.go | 35 ++++ .../{electron/vpnlinux => vpn}/errors.go | 21 ++- .../socket_linux.go => vpn/method_channel.go} | 18 +- client/go/outline/vpn/nmconn_linux.go | 163 ++++++++++++++++ client/go/outline/vpn/tun_linux.go | 48 +++++ client/go/outline/{electron => vpn}/vpn.go | 71 +++---- client/go/outline/vpn/vpn_linux.go | 166 +++++++++++++++++ .../outline/{electron => vpn}/vpn_windows.go | 10 +- go.mod | 2 - go.sum | 6 - 22 files changed, 603 insertions(+), 726 deletions(-) rename client/go/outline/{electron/outline_device.go => device.go} (59%) create mode 100644 client/go/outline/device_linux.go rename client/go/outline/{electron/outline_device_windows.go => device_windows.go} (68%) delete mode 100644 client/go/outline/electron/outline_device_linux.go delete mode 100644 client/go/outline/electron/vpn_linux.go delete mode 100644 client/go/outline/electron/vpnlinux/nmconn_linux.go delete mode 100644 client/go/outline/electron/vpnlinux/routing_linux.go delete mode 100644 client/go/outline/electron/vpnlinux/tun_linux.go rename client/go/outline/{electron/vpnlinux => vpn}/errors.go (74%) rename client/go/outline/{electron/vpnlinux/socket_linux.go => vpn/method_channel.go} (66%) create mode 100644 client/go/outline/vpn/nmconn_linux.go create mode 100644 client/go/outline/vpn/tun_linux.go rename client/go/outline/{electron => vpn}/vpn.go (54%) create mode 100644 client/go/outline/vpn/vpn_linux.go rename client/go/outline/{electron => vpn}/vpn_windows.go (69%) diff --git a/client/electron/electron-builder.json b/client/electron/electron-builder.json index 4b8f65638c..b4ba54c123 100644 --- a/client/electron/electron-builder.json +++ b/client/electron/electron-builder.json @@ -19,7 +19,7 @@ "deb": { "depends": [ - "gconf2", "gconf-service", "libnotify4", "libappindicator1", "libxtst6", "libnss3", + "libnotify4", "libxtst6", "libnss3", "libcap2-bin", "patchelf" ], "afterInstall": "client/electron/debian/after_install.sh" diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index b6cf7a8721..9d6c60d111 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -18,9 +18,11 @@ import { TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; +// TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share. interface VpnConfig { id: string; interfaceName: string; + connectionName: string; ipAddress: string; dnsServers: string[]; routingTableId: number; @@ -34,16 +36,35 @@ let currentRequestId: string | undefined = undefined; export async function establishVpn(request: StartRequestJson) { currentRequestId = request.id; statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); + const config: VpnConfig = { id: currentRequestId, + + // TUN device name, compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203 interfaceName: 'outline-tun0', - ipAddress: '10.0.85.5', + + // Network Manager connection name, Use "TUN Connection" instead of "VPN Connection" + // because Network Manager has a dedicated "VPN Connection" concept that we did not implement + connectionName: 'Outline TUN Connection', + + // TUN IP, compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204 + ipAddress: '10.0.85.1', + + // DNS server list, compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207 dnsServers: ['9.9.9.9'], + + // Outline magic numbers, 7113 and 0x711E visually resembles "T L I E" in "ouTLInE" routingTableId: 7113, - routingPriority: 28958, + routingPriority: 0x711e, protectionMark: 0x711e, + + // The actual transport config transport: JSON.stringify(request.config.transport), }; + await invokeMethod('EstablishVPN', JSON.stringify(config)); statusCb?.(currentRequestId, TunnelStatus.CONNECTED); } diff --git a/client/go/outline/client.go b/client/go/outline/client.go index e8cf3e8a5c..4d00852313 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,17 +42,17 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - client, err := NewClientWithBaseDialers(transportConfig, DefaultBaseTCPDialer(), DefaultBaseUDPDialer()) + client, err := newClientWithBaseDialers(transportConfig, defaultBaseTCPDialer(), defaultBaseUDPDialer()) return &NewClientResult{ Client: client, Error: platerrors.ToPlatformError(err), } } -func DefaultBaseTCPDialer() net.Dialer { return net.Dialer{KeepAlive: -1} } -func DefaultBaseUDPDialer() net.Dialer { return net.Dialer{} } +func defaultBaseTCPDialer() net.Dialer { return net.Dialer{KeepAlive: -1} } +func defaultBaseUDPDialer() net.Dialer { return net.Dialer{} } -func NewClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) { +func newClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) { conf, err := parseConfigFromJSON(transportConfig) if err != nil { return nil, err diff --git a/client/go/outline/electron/outline_device.go b/client/go/outline/device.go similarity index 59% rename from client/go/outline/electron/outline_device.go rename to client/go/outline/device.go index 6c18ae252d..3cacc86571 100644 --- a/client/go/outline/electron/outline_device.go +++ b/client/go/outline/device.go @@ -12,43 +12,58 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package outline import ( "log/slog" - "github.com/Jigsaw-Code/outline-apps/client/go/outline" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/Jigsaw-Code/outline-sdk/network" "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" ) -type outlineDevice struct { +type Device struct { network.IPDevice - c *outline.Client - pkt network.DelegatePacketProxy - routeUDP *bool + c *Client + pkt network.DelegatePacketProxy + supportsUDP *bool remote, fallback network.PacketProxy } -func (d *outlineDevice) Connect() (perr *perrs.PlatformError) { - var err error +type LinuxOptions struct { + FWMark uint32 +} + +type DeviceOptions struct { + LinuxOpts *LinuxOptions +} + +func NewDevice(transportConfig string, opts *DeviceOptions) (*Device, error) { + if opts == nil || opts.LinuxOpts == nil { + return nil, perrs.PlatformError{ + Code: perrs.InternalError, + Message: "must provide at least one platform specific Option", + } + } + return createWithOpts(transportConfig, opts) +} +func (d *Device) Connect() (err error) { d.remote, err = network.NewPacketProxyFromPacketListener(d.c.PacketListener) if err != nil { return errSetupHandler("failed to create datagram handler", err) } - slog.Debug("[OutlineNetDev] remote UDP handler created") + slog.Debug("[Outline] remote UDP handler created") if d.fallback, err = dnstruncate.NewPacketProxy(); err != nil { return errSetupHandler("failed to create datagram handler for DNS fallback", err) } - slog.Debug("[OutlineNetDev] local DNS-fallback UDP handler created") + slog.Debug("[Outline] local DNS-fallback UDP handler created") - if perr = d.RefreshConnectivity(); perr != nil { + if err = d.RefreshConnectivity(); err != nil { return } @@ -56,23 +71,22 @@ func (d *outlineDevice) Connect() (perr *perrs.PlatformError) { if err != nil { return errSetupHandler("failed to configure Outline network stack", err) } - slog.Debug("[OutlineNetDev] lwIP network stack configured") + slog.Debug("[Outline] lwIP network stack configured") - slog.Info("[VPN] successfully connected to Outline server") + slog.Info("[Outline] successfully connected to Outline server") return nil } -func (d *outlineDevice) Close() (err error) { +func (d *Device) Close() (err error) { if d.IPDevice != nil { err = d.IPDevice.Close() } - slog.Info("[VPN] successfully disconnected from Outline server") + slog.Info("[Outline] successfully disconnected from Outline server") return } -func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { - var err error - result := outline.CheckTCPAndUDPConnectivity(d.c) +func (d *Device) RefreshConnectivity() (err error) { + result := CheckTCPAndUDPConnectivity(d.c) if result.TCPError != nil { return result.TCPError } @@ -80,10 +94,10 @@ func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { var proxy network.PacketProxy canHandleUDP := false if result.UDPError != nil { - slog.Warn("[OutlineNetDev] server cannot handle UDP traffic", "err", result.UDPError) + slog.Warn("[Outline] server cannot handle UDP traffic", "err", result.UDPError) proxy = d.fallback } else { - slog.Info("[OutlineNetDev] server can handle UDP traffic") + slog.Info("[Outline] server can handle UDP traffic") proxy = d.remote canHandleUDP = true } @@ -97,18 +111,18 @@ func (d *outlineDevice) RefreshConnectivity() (perr *perrs.PlatformError) { return errSetupHandler("failed to update combined datagram handler", err) } } - d.routeUDP = &canHandleUDP - slog.Info("[OutlineNetDev] UDP handler refreshed", "routeUDP", canHandleUDP) + d.supportsUDP = &canHandleUDP + slog.Info("[OutlineNetDev] UDP handler refreshed", "supportsUDP", canHandleUDP) return nil } -func (d *outlineDevice) RouteUDP() *bool { - return d.routeUDP +func (d *Device) SupportsUDP() *bool { + return d.supportsUDP } -func errSetupHandler(msg string, cause error) *perrs.PlatformError { - slog.Error("[OutlineNetDev] "+msg, "err", cause) - return &perrs.PlatformError{ +func errSetupHandler(msg string, cause error) error { + slog.Error("[Outline] "+msg, "err", cause) + return perrs.PlatformError{ Code: perrs.SetupTrafficHandlerFailed, Message: msg, Cause: perrs.ToPlatformError(cause), diff --git a/client/go/outline/device_linux.go b/client/go/outline/device_linux.go new file mode 100644 index 0000000000..a9d9fac7a6 --- /dev/null +++ b/client/go/outline/device_linux.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package outline + +import "syscall" + +func createWithOpts(transport string, opts *DeviceOptions) (_ *Device, err error) { + d := &Device{} + + control := func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(opts.LinuxOpts.FWMark)) + }) + } + + tcp := defaultBaseTCPDialer() + tcp.Control = control + + udp := defaultBaseUDPDialer() + udp.Control = control + + if d.c, err = newClientWithBaseDialers(transport, tcp, udp); err != nil { + return nil, err + } + return d, nil +} diff --git a/client/go/outline/electron/outline_device_windows.go b/client/go/outline/device_windows.go similarity index 68% rename from client/go/outline/electron/outline_device_windows.go rename to client/go/outline/device_windows.go index 7424a10b6a..3db706ef83 100644 --- a/client/go/outline/electron/outline_device_windows.go +++ b/client/go/outline/device_windows.go @@ -12,12 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package outline -import ( - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" -) +import "errors" -func newOutlineDevice(transport string, fwmark uint32) (_ *outlineDevice, perr *perrs.PlatformError) { - return nil, perrs.NewPlatformError(perrs.InternalError, "not implemented yet") +func createWithOpts(transport string, opts *DeviceOptions) (_ *Device, err error) { + return nil, errors.ErrUnsupported } diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index 443a2a5f8d..9f2ecd1df5 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -39,23 +39,25 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" ) -// Electron specific APIs -const ( - // EstablishVPNAPI initiates a VPN connection and directs all network traffic through Outline. - // - // - Input: a JSON string of vpnConfigJSON. - // - Output: a JSON string of vpnConnectionJSON. - EstablishVPNAPI = "EstablishVPN" +// init initializes the backend module. +// It sets up a default logger based on the OUTLINE_DEBUG environment variable. +func init() { + opts := slog.HandlerOptions{Level: slog.LevelInfo} - // CloseVPNAPI closes an existing VPN connection and restores network traffic to the default - // network interface. - // - // - Input: null - // - Output: null - CloseVPNAPI = "CloseVPN" -) + dbg := os.Getenv("OUTLINE_DEBUG") + if dbg != "" && dbg != "false" && dbg != "0" { + opts.Level = slog.LevelDebug + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) + slog.SetDefault(logger) + + // Register VPN handlers for desktop environments + vpn.RegisterMethodHandlers() +} // InvokeMethod is the unified entry point for TypeScript to invoke various Go functions. // @@ -66,27 +68,10 @@ const ( // //export InvokeMethod func InvokeMethod(method *C.char, input *C.char) C.InvokeMethodResult { - methodName := C.GoString(method) - switch methodName { - // Electron specific APIs - case EstablishVPNAPI: - res, err := EstablishVPN(C.GoString(input)) - return C.InvokeMethodResult{ - Output: newCGoString(res), - ErrorJson: marshalCGoErrorJson(err), - } - - case CloseVPNAPI: - err := CloseVPN() - return C.InvokeMethodResult{ErrorJson: marshalCGoErrorJson(err)} - - // Common APIs - default: - result := outline.InvokeMethod(methodName, C.GoString(input)) - return C.InvokeMethodResult{ - Output: newCGoString(result.Value), - ErrorJson: marshalCGoErrorJson(result.Error), - } + result := outline.InvokeMethod(C.GoString(method), C.GoString(input)) + return C.InvokeMethodResult{ + Output: newCGoString(result.Value), + ErrorJson: marshalCGoErrorJson(result.Error), } } @@ -124,17 +109,3 @@ func marshalCGoErrorJson(e *platerrors.PlatformError) *C.char { } return newCGoString(json) } - -// init initializes the backend module. -// It sets up a default logger based on the OUTLINE_DEBUG environment variable. -func init() { - opts := slog.HandlerOptions{Level: slog.LevelInfo} - - dbg := os.Getenv("OUTLINE_DEBUG") - if dbg != "" && dbg != "false" && dbg != "0" { - opts.Level = slog.LevelDebug - } - - logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) - slog.SetDefault(logger) -} diff --git a/client/go/outline/electron/outline_device_linux.go b/client/go/outline/electron/outline_device_linux.go deleted file mode 100644 index 45648d9781..0000000000 --- a/client/go/outline/electron/outline_device_linux.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "github.com/Jigsaw-Code/outline-apps/client/go/outline" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" -) - -func newOutlineDevice(transport string, fwmark uint32) (_ *outlineDevice, perr *perrs.PlatformError) { - tcpDialer := outline.DefaultBaseTCPDialer() - udpDialer := outline.DefaultBaseUDPDialer() - vpnlinux.ProtectSocket(&tcpDialer, fwmark) - vpnlinux.ProtectSocket(&udpDialer, fwmark) - - var err error - dev := &outlineDevice{} - if dev.c, err = outline.NewClientWithBaseDialers(transport, tcpDialer, udpDialer); err != nil { - return nil, perrs.ToPlatformError(err) - } - return dev, nil -} diff --git a/client/go/outline/electron/vpn_linux.go b/client/go/outline/electron/vpn_linux.go deleted file mode 100644 index b1fd0a79c9..0000000000 --- a/client/go/outline/electron/vpn_linux.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "context" - "io" - "log/slog" - "net" - "sync" - - "github.com/Jigsaw-Code/outline-apps/client/go/outline/electron/vpnlinux" - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" -) - -type linuxVPNConn struct { - id string - status VPNStatus - - fwmark uint32 - tunName string - tunCidr *net.IPNet - dnsIP net.IP - rtID, rtPri int - - ctx context.Context - cancel context.CancelFunc - wgEst, wgCopy sync.WaitGroup - - outline *outlineDevice - - tun *vpnlinux.TUNDevice - nmConn *vpnlinux.NMConnection - route *vpnlinux.RoutingRule -} - -var _ VPNConnection = (*linuxVPNConn)(nil) - -func newVPNConnection(conf *vpnConfigJSON) (_ *linuxVPNConn, perr *perrs.PlatformError) { - c := &linuxVPNConn{ - id: conf.ID, - status: VPNDisconnected, - tunName: conf.InterfaceName, - fwmark: conf.ProtectionMark, - rtID: conf.RoutingTableId, - rtPri: conf.RoutingPriority, - } - - if c.tunName == "" { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN interface name is required") - } - if conf.IPAddress == "" { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN IP is required") - } - _, cidr, err := net.ParseCIDR(conf.IPAddress + "/32") - if c.tunCidr = cidr; err != nil { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "TUN IP is invalid") - } - if c.dnsIP = net.ParseIP(conf.DNSServers[0]); c.dnsIP == nil { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "DNS IP is invalid") - } - if c.rtID < 0 { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "Routing Table ID must be greater than 0") - } - if c.rtPri < 0 { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "Routing Priority must be greater than 0") - } - if conf.TransportConfig == "" { - return nil, perrs.NewPlatformError(perrs.IllegalConfig, "transport config is required") - } - if c.outline, perr = newOutlineDevice(conf.TransportConfig, c.fwmark); perr != nil { - return - } - - c.ctx, c.cancel = context.WithCancel(context.Background()) - return c, nil -} - -func (c *linuxVPNConn) ID() string { return c.id } -func (c *linuxVPNConn) Status() VPNStatus { return c.status } -func (c *linuxVPNConn) RouteUDP() *bool { return c.outline.RouteUDP() } - -func (c *linuxVPNConn) Establish() (perr *perrs.PlatformError) { - c.wgEst.Add(1) - defer c.wgEst.Done() - if c.ctx.Err() != nil { - return &perrs.PlatformError{Code: perrs.OperationCanceled} - } - - c.status = VPNConnecting - defer func() { - if perr == nil { - c.status = VPNConnected - } else { - c.status = VPNDisconnected - } - }() - - if perr = c.outline.Connect(); perr != nil { - return - } - - if c.tun, perr = vpnlinux.NewTUNDevice(c.tunName, c.tunCidr); perr != nil { - return - } - if c.nmConn, perr = vpnlinux.NewNMConnection(c.tun, c.dnsIP); perr != nil { - return - } - if c.route, perr = vpnlinux.NewRoutingRule(c.tun, c.rtID, c.rtPri, c.fwmark); perr != nil { - return - } - - c.wgCopy.Add(2) - go func() { - defer c.wgCopy.Done() - slog.Debug("[IO] Copying traffic from TUN Device -> OutlineDevice...") - n, err := io.Copy(c.outline, c.tun.File) - slog.Debug("[IO] TUN Device -> OutlineDevice done", "n", n, "err", err) - }() - go func() { - defer c.wgCopy.Done() - slog.Debug("[IO] Copying traffic from OutlineDevice -> TUN Device...") - n, err := io.Copy(c.tun.File, c.outline) - slog.Debug("[IO] OutlineDevice -> TUN Device done", "n", n, "err", err) - }() - - return nil -} - -func (c *linuxVPNConn) Close() (perr *perrs.PlatformError) { - if c == nil { - return nil - } - - prevStatus := c.status - c.status = VPNDisconnecting - defer func() { - if perr == nil { - c.status = VPNDisconnected - } else { - c.status = prevStatus - } - }() - - c.cancel() - c.wgEst.Wait() - - if c.route != nil { - perr = c.route.Close() - } - - // All following errors are harmless and can be ignored. - if err := c.nmConn.Close(); err == nil { - c.nmConn = nil - } - if err := c.tun.Close(); err == nil { - c.tun = nil - } - c.outline.Close() - - // Wait for traffic copy go routines to finish - c.wgCopy.Wait() - return -} diff --git a/client/go/outline/electron/vpnlinux/nmconn_linux.go b/client/go/outline/electron/vpnlinux/nmconn_linux.go deleted file mode 100644 index a4659e3390..0000000000 --- a/client/go/outline/electron/vpnlinux/nmconn_linux.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package vpnlinux - -import ( - "encoding/binary" - "log/slog" - "net" - "time" - - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - gonm "github.com/Wifx/gonetworkmanager/v2" -) - -type NMConnection struct { - nm gonm.NetworkManager - ac gonm.ActiveConnection - c gonm.Connection -} - -func NewNMConnection(tun *TUNDevice, dns net.IP) (_ *NMConnection, perr *perrs.PlatformError) { - c := &NMConnection{} - defer func() { - if perr != nil { - c.Close() - } - }() - - var err error - if c.nm, err = gonm.NewNetworkManager(); err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to connect", err) - } - slog.Debug(nmLogPfx + "connected") - - dev, err := c.nm.GetDeviceByIpIface(tun.name) - if err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", tun.name) - } - slog.Debug(nmLogPfx+"located TUN device", "tun", tun.name, "dev", dev.GetPath()) - - if c.ac, perr = waitForActiveConnection(dev); perr != nil { - return nil, perr - } - - if c.c, err = c.ac.GetPropertyConnection(); err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to get the underlying connection", err, "conn", c.ac.GetPath()) - } - slog.Debug(nmLogPfx+"found the underlying connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) - - props, err := c.c.GetSettings() - if err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to read setting values", err, "setting", c.c.GetPath()) - } - slog.Debug(nmLogPfx+"retrieved all setting values", "setting", c.c.GetPath()) - - purgeLegacyIPv6Props(props) - configureDNSProps(props, dns) - - if err := c.c.Update(props); err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to update connection setting", err, "setting", c.c.GetPath()) - } - slog.Debug(nmLogPfx+"saved all new setting values", "setting", c.c.GetPath()) - - slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) - return c, nil -} - -func (c *NMConnection) Close() *perrs.PlatformError { - if c == nil || c.nm == nil { - return nil - } - - if c.ac != nil { - if err := c.nm.DeactivateConnection(c.ac); err != nil { - slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", c.ac.GetPath()) - } - slog.Debug(nmLogPfx+"deactivated connection", "conn", c.ac.GetPath()) - } - if c.c != nil { - if err := c.c.Delete(); err != nil { - return errCloseVPN(nmLogPfx, "failed to delete connection setting", err, "setting", c.c.GetPath()) - } - slog.Debug(nmLogPfx+"connection setting deleted", "setting", c.c.GetPath()) - } - - slog.Info(nmLogPfx+"cleaned up NetworkManager connection", "conn", c.ac.GetPath(), "setting", c.c.GetPath()) - return nil -} - -var waitIntervals = []time.Duration{ - 20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 150 * time.Millisecond, - 200 * time.Millisecond, 500 * time.Millisecond, 1 * time.Second, 2 * time.Second, 4 * time.Second} - -// waitForActiveConnection waits for an gonm.ActiveConnection to be ready. -func waitForActiveConnection(dev gonm.Device) (gonm.ActiveConnection, *perrs.PlatformError) { - for _, interval := range waitIntervals { - slog.Debug(nmLogPfx + "waiting for active connection ...") - time.Sleep(interval) - conn, err := dev.GetPropertyActiveConnection() - if err == nil && conn != nil { - slog.Debug(nmLogPfx+"active connection identified", "dev", dev.GetPath(), "conn", conn.GetPath()) - return conn, nil - } - } - return nil, errSetupVPN(nmLogPfx, "TUN device connection was not ready in time", nil, "dev", dev.GetPath()) -} - -func purgeLegacyIPv6Props(props gonm.ConnectionSettings) { - // These props are legacy IPv6 settings that won't be accepted by the NetworkManager D-Bus API - if ipv6Props, ok := props["ipv6"]; ok { - delete(ipv6Props, "addresses") - delete(ipv6Props, "routes") - } -} - -func configureDNSProps(props gonm.ConnectionSettings, dns4 net.IP) { - // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. - dnsIPv4 := binary.NativeEndian.Uint32(dns4.To4()) - props["ipv4"]["dns"] = []uint32{dnsIPv4} - - // A lower value has a higher priority. - // Negative values will exclude other configurations with a greater value. - props["ipv4"]["dns-priority"] = -99 - - // routing domain to exclude all other DNS resolvers - // https://manpages.ubuntu.com/manpages/jammy/man5/resolved.conf.5.html - props["ipv4"]["dns-search"] = []string{"~."} -} diff --git a/client/go/outline/electron/vpnlinux/routing_linux.go b/client/go/outline/electron/vpnlinux/routing_linux.go deleted file mode 100644 index 63e66bfe86..0000000000 --- a/client/go/outline/electron/vpnlinux/routing_linux.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package vpnlinux - -import ( - "errors" - "log/slog" - - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/vishvananda/netlink" -) - -type RoutingRule struct { - table int - rule *netlink.Rule -} - -func NewRoutingRule(tun *TUNDevice, table, priority int, fwmark uint32) (_ *RoutingRule, perr *perrs.PlatformError) { - r := &RoutingRule{table: table} - defer func() { - if perr != nil { - r.Close() - } - }() - - // Make sure delete previous routing entries - r.Close() - - // ip route add default via "<10.0.85.5>" dev "outline-tun0" table "113" - rt := &netlink.Route{ - LinkIndex: tun.link.Attrs().Index, - Table: r.table, - Gw: tun.ip.IP, - Scope: netlink.SCOPE_LINK, - } - if err := netlink.RouteAdd(rt); err != nil { - return nil, errSetupVPN(nlLogPfx, "failed to add routing entry", err, "table", r.table, "route", rt) - } - slog.Info(nlLogPfx+"routing entry added", "table", r.table, "route", rt) - - // ip rule add not fwmark "0x711E" table "113" priority "456" - r.rule = netlink.NewRule() - r.rule.Priority = priority - r.rule.Family = netlink.FAMILY_ALL - r.rule.Table = r.table - r.rule.Mark = fwmark - r.rule.Invert = true - if err := netlink.RuleAdd(r.rule); err != nil { - return nil, errSetupVPN(nlLogPfx, "failed to add IP rule", err, "rule", r.rule) - } - slog.Info(nlLogPfx+"IP rule added", "rule", r.rule) - - return r, nil -} - -func (r *RoutingRule) Close() *perrs.PlatformError { - if r == nil { - return nil - } - - if r.rule != nil { - if err := netlink.RuleDel(r.rule); err != nil { - return errCloseVPN(nlLogPfx, "failed to delete IP rule", err, "rule", r.rule) - } - slog.Info(nlLogPfx+"deleted IP rule", "rule", r.rule) - r.rule = nil - } - - if r.table > 0 { - filter := &netlink.Route{Table: r.table} - rts, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE) - if err != nil { - return errCloseVPN(nlLogPfx, "failed to list routing entries", err, "table", r.table) - } - - nDel := 0 - var errs error - for _, rt := range rts { - if err := netlink.RouteDel(&rt); err == nil { - slog.Debug(nlLogPfx+"successfully deleted routing entry", "table", r.table, "route", rt) - nDel++ - } else { - slog.Warn(nlLogPfx+"failed to delete routing entry", "table", r.table, "route", rt, "err", err) - errs = errors.Join(errs, err) - } - } - if errs != nil { - return errCloseVPN(nlLogPfx, "failed to delete all routig entries", errs, "table", r.table) - } - if nDel > 0 { - slog.Info(nlLogPfx+"deleted all routing entries", "table", r.table, "n", nDel) - } - } - - return nil -} diff --git a/client/go/outline/electron/vpnlinux/tun_linux.go b/client/go/outline/electron/vpnlinux/tun_linux.go deleted file mode 100644 index 585cc1d3c2..0000000000 --- a/client/go/outline/electron/vpnlinux/tun_linux.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package vpnlinux - -import ( - "errors" - "log/slog" - "net" - "syscall" - - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/songgao/water" - "github.com/vishvananda/netlink" -) - -type TUNDevice struct { - File *water.Interface - - name string - ip *netlink.Addr - link netlink.Link -} - -func NewTUNDevice(name string, ipCidr *net.IPNet) (_ *TUNDevice, perr *perrs.PlatformError) { - var err error - tun := &TUNDevice{name: name} - - // Make sure the previous TUN device is deleted - tun.Close() - - // Make sure we don't leak any resources if anything goes wrong - defer func() { - if perr != nil { - tun.Close() - } - }() - - tun.File, err = water.New(water.Config{ - DeviceType: water.TUN, - PlatformSpecificParams: water.PlatformSpecificParams{ - Name: name, - Persist: false, - }, - }) - if err != nil { - return nil, errSetupVPN(ioLogPfx, "failed to create TUN file", err, "name", name) - } - tun.name = tun.File.Name() - slog.Debug(ioLogPfx+"TUN file created", "name", tun.name) - - if tun.link, err = netlink.LinkByName(tun.name); err != nil { - return nil, errSetupVPN(nlLogPfx, "failed to find the new TUN device", err, "name", tun.name) - } - slog.Debug(nlLogPfx+"TUN device found", "name", tun.name) - - tun.ip = &netlink.Addr{IPNet: ipCidr} - if err = netlink.AddrReplace(tun.link, &netlink.Addr{IPNet: ipCidr}); err != nil { - return nil, errSetupVPN(nlLogPfx, "failed to assign IP to TUN device", - err, "name", tun.name, "ip", ipCidr.String()) - } - slog.Debug(nlLogPfx+"assigned IP to TUN device", "name", tun.name, "ip", tun.ip) - - if err = netlink.LinkSetUp(tun.link); err != nil { - return nil, errSetupVPN(nlLogPfx, "failed to bring up TUN device", err, "name", tun.name) - } - slog.Debug(nlLogPfx+"brought up TUN device", "name", tun.name) - - slog.Info("[TUN] successfully configured TUN device", "name", tun.name) - return tun, nil -} - -func (tun *TUNDevice) Close() *perrs.PlatformError { - if tun == nil { - return nil - } - if tun.name != "" && tun.link == nil { - tun.link, _ = netlink.LinkByName(tun.name) - } - - if tun.File != nil { - if err := tun.File.Close(); err != nil { - return errCloseVPN(ioLogPfx, "failed to close TUN file", err, "name", tun.name) - } - slog.Info(ioLogPfx+"closed TUN device", "name", tun.name) - tun.File = nil - } - - if tun.link != nil { - // Typically the previous Close call should delete the TUN device - if err := netlink.LinkDel(tun.link); err != nil && !errors.Is(err, syscall.ENODEV) { - return errCloseVPN(nlLogPfx, "failed to delete TUN device", err, "name", tun.name) - } - slog.Debug(nlLogPfx+"deleted TUN device", "name", tun.name) - tun.link = nil - } - - return nil -} diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index 19cb95f958..d43a34b097 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -26,8 +26,35 @@ const ( // - Input: the URL string of the resource to fetch // - Output: the content in raw string of the fetched resource MethodFetchResource = "FetchResource" + + // EstablishVPN initiates a VPN connection and directs all network traffic through Outline. + // + // - Input: a JSON string of vpn.configJSON. + // - Output: a JSON string of vpn.connectionJSON. + MethodEstablishVPN = "EstablishVPN" + + // CloseVPN closes an existing VPN connection and restores network traffic to the default + // network interface. + // + // - Input: null + // - Output: null + MethodCloseVPN = "CloseVPN" ) +// Handler is an interface that defines a method for handling requests from TypeScript. +type Handler func(string) (string, error) + +// handlers is a map of registered handlers. +var handlers map[string]Handler + +// RegisterMethodHandler registers a native function handler for the given method. +// +// Instead of having [InvokeMethod] directly depend on other packages, we use dependency inversion +// pattern here. This breaks Go's dependency cycle and makes the code more flexible. +func RegisterMethodHandler(method string, handler Handler) { + handlers[method] = handler +} + // InvokeMethodResult represents the result of an InvokeMethod call. // // We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. @@ -48,6 +75,14 @@ func InvokeMethod(method string, input string) *InvokeMethodResult { } default: + if h, ok := handlers[method]; ok { + val, err := h(input) + return &InvokeMethodResult{ + Value: val, + Error: platerrors.ToPlatformError(err), + } + } + return &InvokeMethodResult{Error: &platerrors.PlatformError{ Code: platerrors.InternalError, Message: fmt.Sprintf("unsupported Go method: %s", method), diff --git a/client/go/outline/electron/vpnlinux/errors.go b/client/go/outline/vpn/errors.go similarity index 74% rename from client/go/outline/electron/vpnlinux/errors.go rename to client/go/outline/vpn/errors.go index 62c89f8049..ae6baaa919 100644 --- a/client/go/outline/electron/vpnlinux/errors.go +++ b/client/go/outline/vpn/errors.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package vpnlinux +package vpn import ( "log/slog" @@ -21,22 +21,27 @@ import ( ) const ( - ioLogPfx = "[IO] " - nlLogPfx = "[NetLink] " - nmLogPfx = "[NetworkManager] " + ioLogPfx = "[IO] " + nmLogPfx = "[NMDBus] " + vpnLogPfx = "[VPN] " ) -func errSetupVPN(pfx, msg string, cause error, params ...any) *perrs.PlatformError { +func errIllegalConfig(msg string, params ...any) error { + return errPlatError(perrs.IllegalConfig, msg, nil, params...) +} + +func errSetupVPN(pfx, msg string, cause error, params ...any) error { return errPlatError(perrs.SetupSystemVPNFailed, pfx+msg, cause, params...) } -func errCloseVPN(pfx, msg string, cause error, params ...any) *perrs.PlatformError { +func errCloseVPN(pfx, msg string, cause error, params ...any) error { return errPlatError(perrs.DisconnectSystemVPNFailed, pfx+msg, cause, params...) } -func errPlatError(code perrs.ErrorCode, msg string, cause error, params ...any) *perrs.PlatformError { +func errPlatError(code perrs.ErrorCode, msg string, cause error, params ...any) error { logParams := append(params, "err", cause) slog.Error(msg, logParams...) + // time.Sleep(60 * time.Second) details := perrs.ErrorDetails{} for i := 1; i < len(params); i += 2 { @@ -44,7 +49,7 @@ func errPlatError(code perrs.ErrorCode, msg string, cause error, params ...any) details[key] = params[i] } } - return &perrs.PlatformError{ + return perrs.PlatformError{ Code: code, Message: msg, Details: details, diff --git a/client/go/outline/electron/vpnlinux/socket_linux.go b/client/go/outline/vpn/method_channel.go similarity index 66% rename from client/go/outline/electron/vpnlinux/socket_linux.go rename to client/go/outline/vpn/method_channel.go index 5c70863a00..13fb101939 100644 --- a/client/go/outline/electron/vpnlinux/socket_linux.go +++ b/client/go/outline/vpn/method_channel.go @@ -12,17 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -package vpnlinux +package vpn -import ( - "net" - "syscall" -) +import "github.com/Jigsaw-Code/outline-apps/client/go/outline" -func ProtectSocket(d *net.Dialer, fwmark uint32) { - d.Control = func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) - }) - } +func RegisterMethodHandlers() { + outline.RegisterMethodHandler(outline.MethodEstablishVPN, EstablishVPN) + outline.RegisterMethodHandler(outline.MethodCloseVPN, func(string) (string, error) { + return "", CloseVPN() + }) } diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go new file mode 100644 index 0000000000..ecbbd69189 --- /dev/null +++ b/client/go/outline/vpn/nmconn_linux.go @@ -0,0 +1,163 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpn + +import ( + "encoding/binary" + "log/slog" + "net" + + gonm "github.com/Wifx/gonetworkmanager/v2" + "golang.org/x/sys/unix" +) + +type nmConnectionOptions struct { + Name string + TUNName string + TUNAddr net.IP + DNSServers []net.IP + FWMark uint32 + RoutingTable uint32 + RoutingPriority uint32 +} + +func (c *linuxVPNConn) establishNMConnection() (err error) { + defer func() { + if err != nil { + c.closeNMConnection() + } + }() + + if c.nm, err = gonm.NewNetworkManager(); err != nil { + return errSetupVPN(nmLogPfx, "failed to connect", err) + } + slog.Debug(nmLogPfx + "connected") + + dev, err := c.nm.GetDeviceByIpIface(c.nmOpts.TUNName) + if err != nil { + return errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) + } + slog.Debug(nmLogPfx+"located TUN device", "tun", c.nmOpts.TUNName, "dev", dev.GetPath()) + + if err = dev.SetPropertyManaged(true); err != nil { + return errSetupVPN(nmLogPfx, "failed to set TUN device to be managed", err, "dev", dev.GetPath()) + } + slog.Debug(nmLogPfx+"set TUN device to be managed", "dev", dev.GetPath()) + + props := make(map[string]map[string]interface{}) + configureCommonProps(props, c.nmOpts) + configureTUNProps(props) + configureIPv4Props(props, c.nmOpts) + slog.Debug(nmLogPfx+"populated NetworkManager connection settings", "settings", props) + + c.ac, err = c.nm.AddAndActivateConnection(props, dev) + if err != nil { + return errSetupVPN(nmLogPfx, "failed to create new connection for device", err, "dev", dev.GetPath()) + } + slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) + return nil +} + +func (c *linuxVPNConn) closeNMConnection() error { + if c == nil || c.nm == nil { + return nil + } + + if c.ac != nil { + if err := c.nm.DeactivateConnection(c.ac); err != nil { + slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", c.ac.GetPath()) + } + slog.Debug(nmLogPfx+"deactivated connection", "conn", c.ac.GetPath()) + + conn, err := c.ac.GetPropertyConnection() + if err == nil { + err = conn.Delete() + } + if err != nil { + return errCloseVPN(nmLogPfx, "failed to delete connection", err, "conn", c.ac.GetPath()) + } + slog.Info(nmLogPfx+"connection deleted", "conn", c.ac.GetPath()) + } + + return nil +} + +func configureCommonProps(props map[string]map[string]interface{}, opts *nmConnectionOptions) { + props["connection"] = map[string]interface{}{ + "id": opts.Name, + "interface-name": opts.TUNName, + } +} + +func configureTUNProps(props map[string]map[string]interface{}) { + props["tun"] = make(map[string]interface{}) + + // The operating mode of the virtual device. + // Allowed values are 1 (tun) to create a layer 3 device and 2 (tap) to create an Ethernet-like layer 2 one. + props["tun"]["mode"] = uint32(1) +} + +func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnectionOptions) { + props["ipv4"] = make(map[string]interface{}) + props["ipv4"]["method"] = "manual" + + // Array of IPv4 addresses. Each address dictionary contains at least 'address' and 'prefix' entries, + // containing the IP address as a string, and the prefix length as a uint32. + + addr := make(map[string]interface{}) + addr["address"] = opts.TUNAddr.To4().String() + addr["prefix"] = uint32(32) + addrs := make([]map[string]interface{}, 0) + addrs = append(addrs, addr) + + props["ipv4"]["address-data"] = addrs + + // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. + dnsIPv4 := binary.NativeEndian.Uint32(opts.DNSServers[0].To4()) + props["ipv4"]["dns"] = []uint32{dnsIPv4} + + // A lower value has a higher priority. + // Negative values will exclude other configurations with a greater value. + props["ipv4"]["dns-priority"] = -99 + + // routing domain to exclude all other DNS resolvers + // https://manpages.ubuntu.com/manpages/jammy/man5/resolved.conf.5.html + props["ipv4"]["dns-search"] = []string{"~."} + + // NetworkManager will add these routing entries: + // - default via 10.0.85.5 dev outline-tun0 table 13579 proto static metric 450 + // - 10.0.85.5 dev outline-tun0 table 13579 proto static scope link metric 450 + props["ipv4"]["route-data"] = []map[string]interface{}{{ + "dest": "0.0.0.0", + "prefix": uint32(0), + "next-hop": opts.TUNAddr.To4().String(), + "table": opts.RoutingTable, + }} + + // Array of dictionaries for routing rules. Each routing rule supports the following options: + // action (y), dport-end (q), dport-start (q), family (i), from (s), from-len (y), fwmark (u), fwmask (u), + // iifname (s), invert (b), ipproto (s), oifname (s), priority (u), sport-end (q), sport-start (q), + // supress-prefixlength (i), table (u), to (s), tos (y), to-len (y), range-end (u), range-start (u). + // + // - not fwmark "0x711E" table "113" priority "456" + props["ipv4"]["routing-rules"] = []map[string]interface{}{{ + "family": unix.AF_INET, + "priority": opts.RoutingPriority, + "fwmark": opts.FWMark, + "fwmask": uint32(0xFFFFFFFF), + "invert": true, + "table": opts.RoutingTable, + }} +} diff --git a/client/go/outline/vpn/tun_linux.go b/client/go/outline/vpn/tun_linux.go new file mode 100644 index 0000000000..7ce8bea734 --- /dev/null +++ b/client/go/outline/vpn/tun_linux.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpn + +import ( + "log/slog" + + "github.com/songgao/water" +) + +func (c *linuxVPNConn) establishTUNDevice() error { + tun, err := water.New(water.Config{ + DeviceType: water.TUN, + PlatformSpecificParams: water.PlatformSpecificParams{ + Name: c.nmOpts.TUNName, + Persist: false, + }, + }) + if err != nil { + return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.TUNName) + } + c.tun = tun + slog.Info(vpnLogPfx+"TUN device created", "name", tun.Name()) + return nil +} + +func (c *linuxVPNConn) closeTUNDevice() error { + if c == nil || c.tun == nil { + return nil + } + if err := c.tun.Close(); err != nil { + return errCloseVPN(vpnLogPfx, "failed to close TUN device", err, "name", c.nmOpts.TUNName) + } + slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) + return nil +} diff --git a/client/go/outline/electron/vpn.go b/client/go/outline/vpn/vpn.go similarity index 54% rename from client/go/outline/electron/vpn.go rename to client/go/outline/vpn/vpn.go index 6e5a70d100..dcf98163af 100644 --- a/client/go/outline/electron/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package vpn import ( "encoding/json" @@ -22,49 +22,50 @@ import ( perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -type vpnConfigJSON struct { +type configJSON struct { ID string `json:"id"` InterfaceName string `json:"interfaceName"` IPAddress string `json:"ipAddress"` DNSServers []string `json:"dnsServers"` - RoutingTableId int `json:"routingTableId"` - RoutingPriority int `json:"routingPriority"` + ConnectionName string `json:"connectionName"` + RoutingTableId uint32 `json:"routingTableId"` + RoutingPriority uint32 `json:"routingPriority"` ProtectionMark uint32 `json:"protectionMark"` TransportConfig string `json:"transport"` } -type vpnConnectionJSON struct { +type connectionJSON struct { ID string `json:"id"` Status string `json:"status"` - RouteUDP *bool `json:"routeUDP"` + RouteUDP *bool `json:"supportsUDP"` } -type VPNStatus string +type Status string const ( - VPNConnected VPNStatus = "Connected" - VPNDisconnected VPNStatus = "Disconnected" - VPNConnecting VPNStatus = "Connecting" - VPNDisconnecting VPNStatus = "Disconnecting" + Unknown Status = "Unknown" + Connected Status = "Connected" + Disconnected Status = "Disconnected" + Connecting Status = "Connecting" + Disconnecting Status = "Disconnecting" ) type VPNConnection interface { ID() string - Status() VPNStatus - RouteUDP() *bool + Status() Status + SupportsUDP() *bool - Establish() *perrs.PlatformError - Close() *perrs.PlatformError + Establish() error + Close() error } var mu sync.Mutex var conn VPNConnection -func EstablishVPN(configStr string) (_ string, perr *perrs.PlatformError) { - var conf vpnConfigJSON - err := json.Unmarshal([]byte(configStr), &conf) - if err != nil { - return "", &perrs.PlatformError{ +func EstablishVPN(configStr string) (_ string, err error) { + var conf configJSON + if err = json.Unmarshal([]byte(configStr), &conf); err != nil { + return "", perrs.PlatformError{ Code: perrs.IllegalConfig, Message: "invalid VPN config format", Cause: perrs.ToPlatformError(err), @@ -72,23 +73,23 @@ func EstablishVPN(configStr string) (_ string, perr *perrs.PlatformError) { } var c VPNConnection - if c, perr = newVPNConnection(&conf); perr != nil { + if c, err = newVPNConnection(&conf); err != nil { return } - if perr = atomicReplaceVPNConn(c); perr != nil { + if err = atomicReplaceVPNConn(c); err != nil { c.Close() return } - slog.Debug("[VPN] Establishing VPN connection ...", "id", c.ID()) - if perr = c.Establish(); perr != nil { + slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID()) + if err = c.Establish(); err != nil { // No need to call c.Close() cuz it's tracked in the global conn already return } - slog.Info("[VPN] VPN connection established", "id", c.ID()) + slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID()) - connJson, err := json.Marshal(vpnConnectionJSON{c.ID(), string(c.Status()), c.RouteUDP()}) + connJson, err := json.Marshal(connectionJSON{c.ID(), string(c.Status()), c.SupportsUDP()}) if err != nil { - return "", &perrs.PlatformError{ + return "", perrs.PlatformError{ Code: perrs.InternalError, Message: "failed to return VPN connection as JSON", Cause: perrs.ToPlatformError(err), @@ -97,31 +98,31 @@ func EstablishVPN(configStr string) (_ string, perr *perrs.PlatformError) { return string(connJson), nil } -func CloseVPN() *perrs.PlatformError { +func CloseVPN() error { mu.Lock() defer mu.Unlock() return closeVPNNoLock() } -func atomicReplaceVPNConn(newConn VPNConnection) *perrs.PlatformError { +func atomicReplaceVPNConn(newConn VPNConnection) error { mu.Lock() defer mu.Unlock() - slog.Debug("[VPN] Creating VPN Connection ...", "id", newConn.ID()) + slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID()) if err := closeVPNNoLock(); err != nil { return err } conn = newConn - slog.Info("[VPN] VPN Connection created", "id", newConn.ID()) + slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID()) return nil } -func closeVPNNoLock() (perr *perrs.PlatformError) { +func closeVPNNoLock() (err error) { if conn == nil { return nil } - slog.Debug("[VPN] Closing existing VPN Connection ...", "id", conn.ID()) - if perr = conn.Close(); perr == nil { - slog.Info("[VPN] VPN Connection closed", "id", conn.ID()) + slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID()) + if err = conn.Close(); err == nil { + slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID()) conn = nil } return diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go new file mode 100644 index 0000000000..fc79fa9a02 --- /dev/null +++ b/client/go/outline/vpn/vpn_linux.go @@ -0,0 +1,166 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpn + +import ( + "context" + "io" + "log/slog" + "net" + "sync" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + gonm "github.com/Wifx/gonetworkmanager/v2" +) + +type linuxVPNConn struct { + id string + status Status + + ctx context.Context + cancel context.CancelFunc + wgEst, wgCopy sync.WaitGroup + + tun io.ReadWriteCloser + outline *outline.Device + + nmOpts *nmConnectionOptions + nm gonm.NetworkManager + ac gonm.ActiveConnection +} + +var _ VPNConnection = (*linuxVPNConn)(nil) + +func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { + c := &linuxVPNConn{ + id: conf.ID, + status: Disconnected, + nmOpts: &nmConnectionOptions{ + Name: conf.ConnectionName, + TUNName: conf.InterfaceName, + TUNAddr: net.ParseIP(conf.IPAddress), + DNSServers: make([]net.IP, 0, 2), + FWMark: conf.ProtectionMark, + RoutingTable: conf.RoutingTableId, + RoutingPriority: conf.RoutingPriority, + }, + } + + if c.nmOpts.Name == "" { + return nil, errIllegalConfig("must provide a valid connection name") + } + if c.nmOpts.TUNName == "" { + return nil, errIllegalConfig("must provide a valid TUN interface name") + } + if c.nmOpts.TUNAddr == nil { + return nil, errIllegalConfig("must provide a valid TUN interface IP") + } + for _, dns := range conf.DNSServers { + dnsIP := net.ParseIP(dns) + if dnsIP == nil { + return nil, errIllegalConfig("DNS server must be a valid IP", "dns", dns) + } + c.nmOpts.DNSServers = append(c.nmOpts.DNSServers, dnsIP) + } + if conf.TransportConfig == "" { + return nil, errIllegalConfig("must provide a transport config") + } + + c.outline, err = outline.NewDevice(conf.TransportConfig, &outline.DeviceOptions{ + LinuxOpts: &outline.LinuxOptions{ + FWMark: c.nmOpts.FWMark, + }, + }) + if err != nil { + return + } + + c.ctx, c.cancel = context.WithCancel(context.Background()) + return c, nil +} + +func (c *linuxVPNConn) ID() string { return c.id } +func (c *linuxVPNConn) Status() Status { return c.status } +func (c *linuxVPNConn) SupportsUDP() *bool { return c.outline.SupportsUDP() } + +func (c *linuxVPNConn) Establish() (err error) { + c.wgEst.Add(1) + defer c.wgEst.Done() + if c.ctx.Err() != nil { + return &perrs.PlatformError{Code: perrs.OperationCanceled} + } + + c.status = Connecting + defer func() { + if err == nil { + c.status = Connected + } else { + c.status = Unknown + } + }() + + if err = c.outline.Connect(); err != nil { + return + } + if err = c.establishTUNDevice(); err != nil { + return + } + if err = c.establishNMConnection(); err != nil { + return + } + + c.wgCopy.Add(2) + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") + n, err := io.Copy(c.outline, c.tun) + slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) + }() + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") + n, err := io.Copy(c.tun, c.outline) + slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) + }() + + return nil +} + +func (c *linuxVPNConn) Close() (err error) { + if c == nil { + return nil + } + + c.status = Disconnecting + defer func() { + if err == nil { + c.status = Disconnected + } else { + c.status = Unknown + } + }() + + c.cancel() + c.wgEst.Wait() + + c.closeNMConnection() + err = c.closeTUNDevice() // this is the only error that matters + c.outline.Close() + + // Wait for traffic copy go routines to finish + c.wgCopy.Wait() + return +} diff --git a/client/go/outline/electron/vpn_windows.go b/client/go/outline/vpn/vpn_windows.go similarity index 69% rename from client/go/outline/electron/vpn_windows.go rename to client/go/outline/vpn/vpn_windows.go index 71eec11563..2887bd3a49 100644 --- a/client/go/outline/electron/vpn_windows.go +++ b/client/go/outline/vpn/vpn_windows.go @@ -12,12 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package vpn -import ( - "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" -) +import "errors" -func newVPNConnection(conf *vpnConfigJSON) (VPNConnection, *platerrors.PlatformError) { - return nil, platerrors.NewPlatformError(platerrors.InternalError, "not implemented yet") +func newVPNConnection(conf *configJSON) (VPNConnection, error) { + return nil, errors.ErrUnsupported } diff --git a/go.mod b/go.mod index 819e66a67d..44ffb1843f 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/google/go-licenses v1.6.0 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.9.0 - github.com/vishvananda/netlink v1.3.0 golang.org/x/mobile v0.0.0-20240716161057-1ad2df20a8b6 golang.org/x/sys v0.22.0 ) @@ -46,7 +45,6 @@ require ( github.com/spf13/cobra v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/src-d/gcfg v1.4.0 // indirect - github.com/vishvananda/netns v0.0.4 // indirect github.com/xanzy/ssh-agent v0.2.1 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opencensus.io v0.23.0 // indirect diff --git a/go.sum b/go.sum index cb377b4be7..14f92288de 100644 --- a/go.sum +++ b/go.sum @@ -324,10 +324,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= -github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -553,10 +549,8 @@ golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= From a9746be5d0d8ccbac7ada118b3a85c5a7d3e674b Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 17:44:51 -0500 Subject: [PATCH 12/27] Resolve code review comment round 1 --- client/electron/debian/after_install.sh | 2 ++ client/electron/vpn_service.ts | 4 ++-- client/go/outline/{device_windows.go => device_others.go} | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) rename client/go/outline/{device_windows.go => device_others.go} (97%) diff --git a/client/electron/debian/after_install.sh b/client/electron/debian/after_install.sh index 1e01a37ade..eca685a976 100644 --- a/client/electron/debian/after_install.sh +++ b/client/electron/debian/after_install.sh @@ -34,4 +34,6 @@ set -eux # > The SUID sandbox helper binary was found, but is not configured correctly. # > Rather than run without sandboxing I'm aborting now. You need to make sure # > that /opt/Outline/chrome-sandbox is owned by root and has mode 4755. +# +# https://github.com/electron/electron/issues/42510 /usr/bin/chmod 4755 /opt/Outline/chrome-sandbox diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 9d6c60d111..df5570da43 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -19,7 +19,7 @@ import { } from '../src/www/app/outline_server_repository/vpn'; // TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share. -interface VpnConfig { +interface EstablishVpnConfig { id: string; interfaceName: string; connectionName: string; @@ -37,7 +37,7 @@ export async function establishVpn(request: StartRequestJson) { currentRequestId = request.id; statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); - const config: VpnConfig = { + const config: EstablishVpnConfig = { id: currentRequestId, // TUN device name, compatible with old code: diff --git a/client/go/outline/device_windows.go b/client/go/outline/device_others.go similarity index 97% rename from client/go/outline/device_windows.go rename to client/go/outline/device_others.go index 3db706ef83..0b84ceb7e4 100644 --- a/client/go/outline/device_windows.go +++ b/client/go/outline/device_others.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !linux + package outline import "errors" From 7586b482009b2af008339388d63b7028945cc57f Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 17:52:37 -0500 Subject: [PATCH 13/27] update vpn_others as well --- client/electron/vpn_service.ts | 4 ++-- client/go/outline/vpn/{vpn_windows.go => vpn_others.go} | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) rename client/go/outline/vpn/{vpn_windows.go => vpn_others.go} (97%) diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index df5570da43..772e6cdfd5 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -19,7 +19,7 @@ import { } from '../src/www/app/outline_server_repository/vpn'; // TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share. -interface EstablishVpnConfig { +interface EstablishVpnRequest { id: string; interfaceName: string; connectionName: string; @@ -37,7 +37,7 @@ export async function establishVpn(request: StartRequestJson) { currentRequestId = request.id; statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); - const config: EstablishVpnConfig = { + const config: EstablishVpnRequest = { id: currentRequestId, // TUN device name, compatible with old code: diff --git a/client/go/outline/vpn/vpn_windows.go b/client/go/outline/vpn/vpn_others.go similarity index 97% rename from client/go/outline/vpn/vpn_windows.go rename to client/go/outline/vpn/vpn_others.go index 2887bd3a49..016f406198 100644 --- a/client/go/outline/vpn/vpn_windows.go +++ b/client/go/outline/vpn/vpn_others.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !linux + package vpn import "errors" From ed601b493d7262a93bff9170ac706d19881f2417 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 18:30:06 -0500 Subject: [PATCH 14/27] Wait for device to be available --- client/electron/vpn_service.ts | 6 +++--- client/go/outline/device.go | 6 ++++-- client/go/outline/method_channel.go | 2 +- client/go/outline/vpn/nmconn_linux.go | 31 +++++++++++++++++++-------- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 772e6cdfd5..6c8f65c8f2 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -40,7 +40,7 @@ export async function establishVpn(request: StartRequestJson) { const config: EstablishVpnRequest = { id: currentRequestId, - // TUN device name, compatible with old code: + // TUN device name, being compatible with old code: // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203 interfaceName: 'outline-tun0', @@ -48,11 +48,11 @@ export async function establishVpn(request: StartRequestJson) { // because Network Manager has a dedicated "VPN Connection" concept that we did not implement connectionName: 'Outline TUN Connection', - // TUN IP, compatible with old code: + // TUN IP, being compatible with old code: // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204 ipAddress: '10.0.85.1', - // DNS server list, compatible with old code: + // DNS server list, being compatible with old code: // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207 dnsServers: ['9.9.9.9'], diff --git a/client/go/outline/device.go b/client/go/outline/device.go index 3cacc86571..ef844a3129 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -86,8 +86,10 @@ func (d *Device) Close() (err error) { } func (d *Device) RefreshConnectivity() (err error) { + slog.Debug("[Outine] Testing connectivity of Outline server ...") result := CheckTCPAndUDPConnectivity(d.c) if result.TCPError != nil { + slog.Warn("[Outline] Outline server connectivity test failed", "err", result.TCPError) return result.TCPError } @@ -97,7 +99,7 @@ func (d *Device) RefreshConnectivity() (err error) { slog.Warn("[Outline] server cannot handle UDP traffic", "err", result.UDPError) proxy = d.fallback } else { - slog.Info("[Outline] server can handle UDP traffic") + slog.Debug("[Outline] server can handle UDP traffic") proxy = d.remote canHandleUDP = true } @@ -112,7 +114,7 @@ func (d *Device) RefreshConnectivity() (err error) { } } d.supportsUDP = &canHandleUDP - slog.Info("[OutlineNetDev] UDP handler refreshed", "supportsUDP", canHandleUDP) + slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", canHandleUDP) return nil } diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index d43a34b097..61daceaf5c 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -45,7 +45,7 @@ const ( type Handler func(string) (string, error) // handlers is a map of registered handlers. -var handlers map[string]Handler +var handlers = make(map[string]Handler) // RegisterMethodHandler registers a native function handler for the given method. // diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index ecbbd69189..9a6e79612a 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -18,6 +18,7 @@ import ( "encoding/binary" "log/slog" "net" + "time" gonm "github.com/Wifx/gonetworkmanager/v2" "golang.org/x/sys/unix" @@ -45,7 +46,7 @@ func (c *linuxVPNConn) establishNMConnection() (err error) { } slog.Debug(nmLogPfx + "connected") - dev, err := c.nm.GetDeviceByIpIface(c.nmOpts.TUNName) + dev, err := c.waitForDeviceToBeAvailable() if err != nil { return errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) } @@ -94,6 +95,18 @@ func (c *linuxVPNConn) closeNMConnection() error { return nil } +func (c *linuxVPNConn) waitForDeviceToBeAvailable() (dev gonm.Device, err error) { + for retries := 20; retries > 0; retries-- { + dev, err = c.nm.GetDeviceByIpIface(c.nmOpts.TUNName) + if dev != nil && err == nil { + return + } + slog.Warn(nmLogPfx+"waiting for TUN device to be available", "err", err) + time.Sleep(50 * time.Millisecond) + } + return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) +} + func configureCommonProps(props map[string]map[string]interface{}, opts *nmConnectionOptions) { props["connection"] = map[string]interface{}{ "id": opts.Name, @@ -102,20 +115,20 @@ func configureCommonProps(props map[string]map[string]interface{}, opts *nmConne } func configureTUNProps(props map[string]map[string]interface{}) { - props["tun"] = make(map[string]interface{}) - - // The operating mode of the virtual device. - // Allowed values are 1 (tun) to create a layer 3 device and 2 (tap) to create an Ethernet-like layer 2 one. - props["tun"]["mode"] = uint32(1) + props["tun"] = map[string]interface{}{ + // The operating mode of the virtual device. + // Allowed values are 1 (tun) to create a layer 3 device and 2 (tap) to create an Ethernet-like layer 2 one. + "mode": uint32(1), + } } func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnectionOptions) { - props["ipv4"] = make(map[string]interface{}) - props["ipv4"]["method"] = "manual" + props["ipv4"] = map[string]interface{}{ + "method": "manual", + } // Array of IPv4 addresses. Each address dictionary contains at least 'address' and 'prefix' entries, // containing the IP address as a string, and the prefix length as a uint32. - addr := make(map[string]interface{}) addr["address"] = opts.TUNAddr.To4().String() addr["prefix"] = uint32(32) From b5f43ec45c1f000f8131d4368bf0ec9b1f617735 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 18:54:33 -0500 Subject: [PATCH 15/27] Simplify NM config structure --- client/go/outline/vpn/nmconn_linux.go | 98 ++++++++++++++------------- client/go/outline/vpn/vpn_linux.go | 14 ++-- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index 9a6e79612a..95fc69e89d 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -27,8 +27,8 @@ import ( type nmConnectionOptions struct { Name string TUNName string - TUNAddr net.IP - DNSServers []net.IP + TUNAddr4 net.IP + DNSServers4 []net.IP FWMark uint32 RoutingTable uint32 RoutingPriority uint32 @@ -125,52 +125,54 @@ func configureTUNProps(props map[string]map[string]interface{}) { func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnectionOptions) { props["ipv4"] = map[string]interface{}{ "method": "manual", + + // Array of IPv4 addresses. Each address dictionary contains at least 'address' and 'prefix' entries, + // containing the IP address as a string, and the prefix length as a uint32. + "address-data": []map[string]interface{}{{ + "address": opts.TUNAddr4.String(), + "prefix": uint32(32), + }}, + + // A lower value has a higher priority. + // Negative values will exclude other configurations with a greater value. + "dns-priority": -99, + + // routing domain to exclude all other DNS resolvers + // https://manpages.ubuntu.com/manpages/jammy/man5/resolved.conf.5.html + "dns-search": []string{"~."}, + + // NetworkManager will add these routing entries: + // - default via 10.0.85.5 dev outline-tun0 table 13579 proto static metric 450 + // - 10.0.85.5 dev outline-tun0 table 13579 proto static scope link metric 450 + "route-data": []map[string]interface{}{{ + "dest": "0.0.0.0", + "prefix": uint32(0), + "next-hop": opts.TUNAddr4.String(), + "table": opts.RoutingTable, + }}, + + // Array of dictionaries for routing rules. Each routing rule supports the following options: + // action (y), dport-end (q), dport-start (q), family (i), from (s), from-len (y), fwmark (u), fwmask (u), + // iifname (s), invert (b), ipproto (s), oifname (s), priority (u), sport-end (q), sport-start (q), + // supress-prefixlength (i), table (u), to (s), tos (y), to-len (y), range-end (u), range-start (u). + // + // - not fwmark "0x711E" table "113" priority "456" + "routing-rules": []map[string]interface{}{{ + "family": unix.AF_INET, + "priority": opts.RoutingPriority, + "fwmark": opts.FWMark, + "fwmask": uint32(0xFFFFFFFF), + "invert": true, + "table": opts.RoutingTable, + }}, + } + + dnsList := make([]uint32, 0, len(opts.DNSServers4)) + for _, dns := range opts.DNSServers4 { + // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. + dnsList = append(dnsList, binary.NativeEndian.Uint32(dns)) } - // Array of IPv4 addresses. Each address dictionary contains at least 'address' and 'prefix' entries, - // containing the IP address as a string, and the prefix length as a uint32. - addr := make(map[string]interface{}) - addr["address"] = opts.TUNAddr.To4().String() - addr["prefix"] = uint32(32) - addrs := make([]map[string]interface{}, 0) - addrs = append(addrs, addr) - - props["ipv4"]["address-data"] = addrs - - // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. - dnsIPv4 := binary.NativeEndian.Uint32(opts.DNSServers[0].To4()) - props["ipv4"]["dns"] = []uint32{dnsIPv4} - - // A lower value has a higher priority. - // Negative values will exclude other configurations with a greater value. - props["ipv4"]["dns-priority"] = -99 - - // routing domain to exclude all other DNS resolvers - // https://manpages.ubuntu.com/manpages/jammy/man5/resolved.conf.5.html - props["ipv4"]["dns-search"] = []string{"~."} - - // NetworkManager will add these routing entries: - // - default via 10.0.85.5 dev outline-tun0 table 13579 proto static metric 450 - // - 10.0.85.5 dev outline-tun0 table 13579 proto static scope link metric 450 - props["ipv4"]["route-data"] = []map[string]interface{}{{ - "dest": "0.0.0.0", - "prefix": uint32(0), - "next-hop": opts.TUNAddr.To4().String(), - "table": opts.RoutingTable, - }} - - // Array of dictionaries for routing rules. Each routing rule supports the following options: - // action (y), dport-end (q), dport-start (q), family (i), from (s), from-len (y), fwmark (u), fwmask (u), - // iifname (s), invert (b), ipproto (s), oifname (s), priority (u), sport-end (q), sport-start (q), - // supress-prefixlength (i), table (u), to (s), tos (y), to-len (y), range-end (u), range-start (u). - // - // - not fwmark "0x711E" table "113" priority "456" - props["ipv4"]["routing-rules"] = []map[string]interface{}{{ - "family": unix.AF_INET, - "priority": opts.RoutingPriority, - "fwmark": opts.FWMark, - "fwmask": uint32(0xFFFFFFFF), - "invert": true, - "table": opts.RoutingTable, - }} + // Array of IP addresses of DNS servers (as network-byte-order integers) + props["ipv4"]["dns"] = dnsList } diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index fc79fa9a02..786098f20c 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -51,8 +51,8 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { nmOpts: &nmConnectionOptions{ Name: conf.ConnectionName, TUNName: conf.InterfaceName, - TUNAddr: net.ParseIP(conf.IPAddress), - DNSServers: make([]net.IP, 0, 2), + TUNAddr4: net.ParseIP(conf.IPAddress).To4(), + DNSServers4: make([]net.IP, 0, 2), FWMark: conf.ProtectionMark, RoutingTable: conf.RoutingTableId, RoutingPriority: conf.RoutingPriority, @@ -65,15 +65,15 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { if c.nmOpts.TUNName == "" { return nil, errIllegalConfig("must provide a valid TUN interface name") } - if c.nmOpts.TUNAddr == nil { - return nil, errIllegalConfig("must provide a valid TUN interface IP") + if c.nmOpts.TUNAddr4 == nil { + return nil, errIllegalConfig("must provide a valid TUN interface IP(v4)") } for _, dns := range conf.DNSServers { - dnsIP := net.ParseIP(dns) + dnsIP := net.ParseIP(dns).To4() if dnsIP == nil { - return nil, errIllegalConfig("DNS server must be a valid IP", "dns", dns) + return nil, errIllegalConfig("DNS server must be a valid IP(v4)", "dns", dns) } - c.nmOpts.DNSServers = append(c.nmOpts.DNSServers, dnsIP) + c.nmOpts.DNSServers4 = append(c.nmOpts.DNSServers4, dnsIP) } if conf.TransportConfig == "" { return nil, errIllegalConfig("must provide a transport config") From e2bde4d082d1e6571cb0823069894af418a1fdb8 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 6 Dec 2024 19:09:15 -0500 Subject: [PATCH 16/27] Add retry for create connection --- client/go/outline/vpn/nmconn_linux.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index 95fc69e89d..e96dab73fd 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -63,7 +63,16 @@ func (c *linuxVPNConn) establishNMConnection() (err error) { configureIPv4Props(props, c.nmOpts) slog.Debug(nmLogPfx+"populated NetworkManager connection settings", "settings", props) - c.ac, err = c.nm.AddAndActivateConnection(props, dev) + // The previous SetPropertyManaged call needs some time to take effect + for retries := 10; retries > 0; retries-- { + slog.Debug(nmLogPfx+"trying to create connection for TUN device ...", "dev", dev.GetPath()) + c.ac, err = c.nm.AddAndActivateConnection(props, dev) + if err == nil { + break + } + slog.Debug(nmLogPfx+"waiting for TUN device being managed", "err", err) + time.Sleep(50 * time.Millisecond) + } if err != nil { return errSetupVPN(nmLogPfx, "failed to create new connection for device", err, "dev", dev.GetPath()) } @@ -97,11 +106,12 @@ func (c *linuxVPNConn) closeNMConnection() error { func (c *linuxVPNConn) waitForDeviceToBeAvailable() (dev gonm.Device, err error) { for retries := 20; retries > 0; retries-- { + slog.Debug(nmLogPfx+"trying to find TUN device ...", "tun", c.nmOpts.TUNName) dev, err = c.nm.GetDeviceByIpIface(c.nmOpts.TUNName) if dev != nil && err == nil { return } - slog.Warn(nmLogPfx+"waiting for TUN device to be available", "err", err) + slog.Debug(nmLogPfx+"waiting for TUN device to be available", "err", err) time.Sleep(50 * time.Millisecond) } return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) From bf93a3a89cf39ab05326740e2a35d116cd19efa9 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 9 Dec 2024 18:34:26 -0500 Subject: [PATCH 17/27] resolve code review comments RD2 --- client/go/outline/vpn/nmconn_linux.go | 37 +++++++------------- client/go/outline/vpn/tun_linux.go | 38 ++++++++++++-------- client/go/outline/vpn/vpn.go | 37 +++++++++++++++++--- client/go/outline/vpn/vpn_linux.go | 50 +++++++++++++++++---------- 4 files changed, 100 insertions(+), 62 deletions(-) diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index e96dab73fd..bc7e28b88d 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -46,7 +46,7 @@ func (c *linuxVPNConn) establishNMConnection() (err error) { } slog.Debug(nmLogPfx + "connected") - dev, err := c.waitForDeviceToBeAvailable() + dev, err := waitForTUNDeviceToBeAvailable(c.nm, c.nmOpts.TUNName) if err != nil { return errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) } @@ -63,8 +63,8 @@ func (c *linuxVPNConn) establishNMConnection() (err error) { configureIPv4Props(props, c.nmOpts) slog.Debug(nmLogPfx+"populated NetworkManager connection settings", "settings", props) - // The previous SetPropertyManaged call needs some time to take effect - for retries := 10; retries > 0; retries-- { + // The previous SetPropertyManaged call needs some time to take effect (typically within 50ms) + for retries := 20; retries > 0; retries-- { slog.Debug(nmLogPfx+"trying to create connection for TUN device ...", "dev", dev.GetPath()) c.ac, err = c.nm.AddAndActivateConnection(props, dev) if err == nil { @@ -104,19 +104,6 @@ func (c *linuxVPNConn) closeNMConnection() error { return nil } -func (c *linuxVPNConn) waitForDeviceToBeAvailable() (dev gonm.Device, err error) { - for retries := 20; retries > 0; retries-- { - slog.Debug(nmLogPfx+"trying to find TUN device ...", "tun", c.nmOpts.TUNName) - dev, err = c.nm.GetDeviceByIpIface(c.nmOpts.TUNName) - if dev != nil && err == nil { - return - } - slog.Debug(nmLogPfx+"waiting for TUN device to be available", "err", err) - time.Sleep(50 * time.Millisecond) - } - return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) -} - func configureCommonProps(props map[string]map[string]interface{}, opts *nmConnectionOptions) { props["connection"] = map[string]interface{}{ "id": opts.Name, @@ -133,6 +120,12 @@ func configureTUNProps(props map[string]map[string]interface{}) { } func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnectionOptions) { + dnsList := make([]uint32, 0, len(opts.DNSServers4)) + for _, dns := range opts.DNSServers4 { + // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. + dnsList = append(dnsList, binary.NativeEndian.Uint32(dns)) + } + props["ipv4"] = map[string]interface{}{ "method": "manual", @@ -143,6 +136,9 @@ func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnect "prefix": uint32(32), }}, + // Array of IP addresses of DNS servers (as network-byte-order integers) + "dns": dnsList, + // A lower value has a higher priority. // Negative values will exclude other configurations with a greater value. "dns-priority": -99, @@ -176,13 +172,4 @@ func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnect "table": opts.RoutingTable, }}, } - - dnsList := make([]uint32, 0, len(opts.DNSServers4)) - for _, dns := range opts.DNSServers4 { - // net.IP is already BigEndian, if we use BigEndian.Uint32, it will be reversed back to LittleEndian. - dnsList = append(dnsList, binary.NativeEndian.Uint32(dns)) - } - - // Array of IP addresses of DNS servers (as network-byte-order integers) - props["ipv4"]["dns"] = dnsList } diff --git a/client/go/outline/vpn/tun_linux.go b/client/go/outline/vpn/tun_linux.go index 7ce8bea734..f19f9917f2 100644 --- a/client/go/outline/vpn/tun_linux.go +++ b/client/go/outline/vpn/tun_linux.go @@ -15,34 +15,44 @@ package vpn import ( + "fmt" + "io" "log/slog" + "time" + gonm "github.com/Wifx/gonetworkmanager/v2" "github.com/songgao/water" ) -func (c *linuxVPNConn) establishTUNDevice() error { +// newTUNDevice creates a non-persist layer 3 TUN device with the given name. +func newTUNDevice(name string) (io.ReadWriteCloser, error) { tun, err := water.New(water.Config{ DeviceType: water.TUN, PlatformSpecificParams: water.PlatformSpecificParams{ - Name: c.nmOpts.TUNName, + Name: name, Persist: false, }, }) if err != nil { - return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.TUNName) + return nil, err } - c.tun = tun - slog.Info(vpnLogPfx+"TUN device created", "name", tun.Name()) - return nil + if tun.Name() != name { + return nil, fmt.Errorf("TUN device name mismatch: requested `%s`, created `%s`", name, tun.Name()) + } + return tun, nil } -func (c *linuxVPNConn) closeTUNDevice() error { - if c == nil || c.tun == nil { - return nil - } - if err := c.tun.Close(); err != nil { - return errCloseVPN(vpnLogPfx, "failed to close TUN device", err, "name", c.nmOpts.TUNName) +// waitForTUNDeviceToBeAvailable waits for the TUN device with the given name to be available +// in the specific NetworkManager. +func waitForTUNDeviceToBeAvailable(nm gonm.NetworkManager, name string) (dev gonm.Device, err error) { + for retries := 20; retries > 0; retries-- { + slog.Debug(nmLogPfx+"trying to find TUN device ...", "tun", name) + dev, err = nm.GetDeviceByIpIface(name) + if dev != nil && err == nil { + return + } + slog.Debug(nmLogPfx+"waiting for TUN device to be available", "err", err) + time.Sleep(50 * time.Millisecond) } - slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) - return nil + return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", name) } diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index dcf98163af..1ad90c22d1 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -22,6 +22,8 @@ import ( perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) +// configJSON represents the JSON structure for setting up a VPN connection. +// This is typically passed from TypeScript. type configJSON struct { ID string `json:"id"` InterfaceName string `json:"interfaceName"` @@ -34,34 +36,55 @@ type configJSON struct { TransportConfig string `json:"transport"` } +// connectionJSON defines the JSON structure of a [VPNConnection]. +// This is typically returned to TypeScript. type connectionJSON struct { ID string `json:"id"` Status string `json:"status"` RouteUDP *bool `json:"supportsUDP"` } +// Status defines the possible states of a VPN connection. type Status string +// Constants representing the different VPN connection statuses. const ( - Unknown Status = "Unknown" - Connected Status = "Connected" - Disconnected Status = "Disconnected" - Connecting Status = "Connecting" - Disconnecting Status = "Disconnecting" + StatusUnknown Status = "Unknown" + StatusConnected Status = "Connected" + StatusDisconnected Status = "Disconnected" + StatusConnecting Status = "Connecting" + StatusDisconnecting Status = "Disconnecting" ) +// VPNConnection is a platform neutral interface of a VPN connection. type VPNConnection interface { + // ID returns the unique identifier of this VPNConnection. + // Typically it is passed in from the TypeScript through configJson. ID() string + + // Status returns the current Status of the VPNConnection. Status() Status + + // SupportsUDP indicates whether the remote proxy can handle UDP traffic. + // nil means unknown. SupportsUDP() *bool + // Establish tries to connect this VPNConnection. Establish() error + + // Close tries to disconnect this VPNConnection. Close() error } +// The global singleton VPN connection. +// This package allows at most one active VPN connection at the same time. var mu sync.Mutex var conn VPNConnection +// EstablishVPN establishes a new active [VPNConnection] with the given configuration. +// It will first close any active [VPNConnection] using [CloseVPN], and then mark the +// newly created [VPNConnection] as the currently active connection. +// It returns the connectionJSON as a string, or an error if the connection fails. func EstablishVPN(configStr string) (_ string, err error) { var conf configJSON if err = json.Unmarshal([]byte(configStr), &conf); err != nil { @@ -98,12 +121,14 @@ func EstablishVPN(configStr string) (_ string, err error) { return string(connJson), nil } +// CloseVPN closes the currently active [VPNConnection]. func CloseVPN() error { mu.Lock() defer mu.Unlock() return closeVPNNoLock() } +// atomicReplaceVPNConn atomically replaces the global conn with newConn. func atomicReplaceVPNConn(newConn VPNConnection) error { mu.Lock() defer mu.Unlock() @@ -116,6 +141,8 @@ func atomicReplaceVPNConn(newConn VPNConnection) error { return nil } +// closeVPNNoLock closes the current VPN connection stored in conn without acquiring +// the mutex. It is assumed that the caller holds the mutex. func closeVPNNoLock() (err error) { if conn == nil { return nil diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index 786098f20c..57a55d7766 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -26,6 +26,7 @@ import ( gonm "github.com/Wifx/gonetworkmanager/v2" ) +// linuxVPNConn implements a [VPNConnection] on Linux platform. type linuxVPNConn struct { id string status Status @@ -34,8 +35,8 @@ type linuxVPNConn struct { cancel context.CancelFunc wgEst, wgCopy sync.WaitGroup - tun io.ReadWriteCloser - outline *outline.Device + tun io.ReadWriteCloser + proxy *outline.Device nmOpts *nmConnectionOptions nm gonm.NetworkManager @@ -44,10 +45,13 @@ type linuxVPNConn struct { var _ VPNConnection = (*linuxVPNConn)(nil) +// newVPNConnection creates a new Linux specific [VPNConnection]. +// The newly connection will be [StatusDisconnected] initially, you need to call the +// Establish() in order to make it [StatusConnected]. func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { c := &linuxVPNConn{ id: conf.ID, - status: Disconnected, + status: StatusDisconnected, nmOpts: &nmConnectionOptions{ Name: conf.ConnectionName, TUNName: conf.InterfaceName, @@ -79,7 +83,7 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { return nil, errIllegalConfig("must provide a transport config") } - c.outline, err = outline.NewDevice(conf.TransportConfig, &outline.DeviceOptions{ + c.proxy, err = outline.NewDevice(conf.TransportConfig, &outline.DeviceOptions{ LinuxOpts: &outline.LinuxOptions{ FWMark: c.nmOpts.FWMark, }, @@ -94,8 +98,9 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { func (c *linuxVPNConn) ID() string { return c.id } func (c *linuxVPNConn) Status() Status { return c.status } -func (c *linuxVPNConn) SupportsUDP() *bool { return c.outline.SupportsUDP() } +func (c *linuxVPNConn) SupportsUDP() *bool { return c.proxy.SupportsUDP() } +// Establish tries to establish this [VPNConnection], and makes it [StatusConnected]. func (c *linuxVPNConn) Establish() (err error) { c.wgEst.Add(1) defer c.wgEst.Done() @@ -103,21 +108,22 @@ func (c *linuxVPNConn) Establish() (err error) { return &perrs.PlatformError{Code: perrs.OperationCanceled} } - c.status = Connecting + c.status = StatusConnecting defer func() { if err == nil { - c.status = Connected + c.status = StatusConnected } else { - c.status = Unknown + c.status = StatusUnknown } }() - if err = c.outline.Connect(); err != nil { + if err = c.proxy.Connect(); err != nil { return } - if err = c.establishTUNDevice(); err != nil { - return + if c.tun, err = newTUNDevice(c.nmOpts.TUNName); err != nil { + return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.Name) } + slog.Info(vpnLogPfx+"TUN device created", "name", c.nmOpts.TUNName) if err = c.establishNMConnection(); err != nil { return } @@ -126,30 +132,31 @@ func (c *linuxVPNConn) Establish() (err error) { go func() { defer c.wgCopy.Done() slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") - n, err := io.Copy(c.outline, c.tun) + n, err := io.Copy(c.proxy, c.tun) slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) }() go func() { defer c.wgCopy.Done() slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") - n, err := io.Copy(c.tun, c.outline) + n, err := io.Copy(c.tun, c.proxy) slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) }() return nil } +// Close tries to close this [VPNConnection] and make it [StatusDisconnected]. func (c *linuxVPNConn) Close() (err error) { if c == nil { return nil } - c.status = Disconnecting + c.status = StatusDisconnecting defer func() { if err == nil { - c.status = Disconnected + c.status = StatusDisconnected } else { - c.status = Unknown + c.status = StatusUnknown } }() @@ -157,8 +164,15 @@ func (c *linuxVPNConn) Close() (err error) { c.wgEst.Wait() c.closeNMConnection() - err = c.closeTUNDevice() // this is the only error that matters - c.outline.Close() + if c.tun != nil { + // this is the only error that matters + if err = c.tun.Close(); err != nil { + err = errCloseVPN(vpnLogPfx, "failed to close TUN device", err, "name", c.nmOpts.TUNName) + } else { + slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) + } + } + c.proxy.Close() // Wait for traffic copy go routines to finish c.wgCopy.Wait() From 933d6935ac9e2cce1a9d7f8fc48d159d099a0d13 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 9 Dec 2024 20:10:57 -0500 Subject: [PATCH 18/27] code review comment RD3 --- client/go/outline/client.go | 5 +--- .../{device_linux.go => client_linux.go} | 30 ++++++++++--------- client/go/outline/device.go | 10 ++----- client/go/outline/device_others.go | 23 -------------- client/go/outline/vpn/vpn_linux.go | 9 ++---- 5 files changed, 23 insertions(+), 54 deletions(-) rename client/go/outline/{device_linux.go => client_linux.go} (65%) delete mode 100644 client/go/outline/device_others.go diff --git a/client/go/outline/client.go b/client/go/outline/client.go index 4d00852313..cc39396bb0 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,16 +42,13 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - client, err := newClientWithBaseDialers(transportConfig, defaultBaseTCPDialer(), defaultBaseUDPDialer()) + client, err := newClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{}) return &NewClientResult{ Client: client, Error: platerrors.ToPlatformError(err), } } -func defaultBaseTCPDialer() net.Dialer { return net.Dialer{KeepAlive: -1} } -func defaultBaseUDPDialer() net.Dialer { return net.Dialer{} } - func newClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) { conf, err := parseConfigFromJSON(transportConfig) if err != nil { diff --git a/client/go/outline/device_linux.go b/client/go/outline/client_linux.go similarity index 65% rename from client/go/outline/device_linux.go rename to client/go/outline/client_linux.go index a9d9fac7a6..007bebcf13 100644 --- a/client/go/outline/device_linux.go +++ b/client/go/outline/client_linux.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Outline Authors +// Copyright 2023 The Outline Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,25 +14,27 @@ package outline -import "syscall" - -func createWithOpts(transport string, opts *DeviceOptions) (_ *Device, err error) { - d := &Device{} +import ( + "net" + "syscall" +) +// NewClient creates a new Outline client from a configuration string. +func NewClientWithFWMark(transportConfig string, fwmark uint32) (*Client, error) { control := func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(opts.LinuxOpts.FWMark)) + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) }) } - tcp := defaultBaseTCPDialer() - tcp.Control = control - - udp := defaultBaseUDPDialer() - udp.Control = control + tcp := net.Dialer{ + Control: control, + KeepAlive: -1, + } - if d.c, err = newClientWithBaseDialers(transport, tcp, udp); err != nil { - return nil, err + udp := net.Dialer{ + Control: control, } - return d, nil + + return newClientWithBaseDialers(transportConfig, tcp, udp) } diff --git a/client/go/outline/device.go b/client/go/outline/device.go index ef844a3129..29e03d16d4 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -41,14 +41,10 @@ type DeviceOptions struct { LinuxOpts *LinuxOptions } -func NewDevice(transportConfig string, opts *DeviceOptions) (*Device, error) { - if opts == nil || opts.LinuxOpts == nil { - return nil, perrs.PlatformError{ - Code: perrs.InternalError, - Message: "must provide at least one platform specific Option", - } +func NewDevice(c *Client) *Device { + return &Device{ + c: c, } - return createWithOpts(transportConfig, opts) } func (d *Device) Connect() (err error) { diff --git a/client/go/outline/device_others.go b/client/go/outline/device_others.go deleted file mode 100644 index 0b84ceb7e4..0000000000 --- a/client/go/outline/device_others.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build !linux - -package outline - -import "errors" - -func createWithOpts(transport string, opts *DeviceOptions) (_ *Device, err error) { - return nil, errors.ErrUnsupported -} diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index 57a55d7766..f67d00e4e1 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -83,14 +83,11 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { return nil, errIllegalConfig("must provide a transport config") } - c.proxy, err = outline.NewDevice(conf.TransportConfig, &outline.DeviceOptions{ - LinuxOpts: &outline.LinuxOptions{ - FWMark: c.nmOpts.FWMark, - }, - }) + oc, err := outline.NewClientWithFWMark(conf.TransportConfig, c.nmOpts.FWMark) if err != nil { - return + return nil, err } + c.proxy = outline.NewDevice(oc) c.ctx, c.cancel = context.WithCancel(context.Background()) return c, nil From 70f83033fa6629868a3415971f4af6d9cf3b54f6 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 9 Dec 2024 20:23:04 -0500 Subject: [PATCH 19/27] Add NetworkManager documentation link --- client/go/outline/vpn/nmconn_linux.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index bc7e28b88d..f5668577a2 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -104,6 +104,9 @@ func (c *linuxVPNConn) closeNMConnection() error { return nil } +// NetworkManager settings reference: +// https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/nm-settings-dbus.html + func configureCommonProps(props map[string]map[string]interface{}, opts *nmConnectionOptions) { props["connection"] = map[string]interface{}{ "id": opts.Name, @@ -141,6 +144,7 @@ func configureIPv4Props(props map[string]map[string]interface{}, opts *nmConnect // A lower value has a higher priority. // Negative values will exclude other configurations with a greater value. + // The default value is 50 for VPN connections (and 100 for regular connections). "dns-priority": -99, // routing domain to exclude all other DNS resolvers From a6606f8c32876ae89c8397e9b80c4bdd31191331 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 16 Dec 2024 15:55:47 -0500 Subject: [PATCH 20/27] refactor vpn package architecture --- client/electron/vpn_service.ts | 52 ++--- client/go/outline/device.go | 47 ++--- client/go/outline/electron/go_plugin.go | 32 ++-- client/go/outline/method_channel.go | 33 ++-- client/go/outline/vpn.go | 82 ++++++++ client/go/outline/vpn/dialer.go | 19 ++ .../{client_linux.go => vpn/dialer_linux.go} | 31 ++- .../{method_channel.go => dialer_others.go} | 15 +- client/go/outline/vpn/errors.go | 7 +- client/go/outline/vpn/vpn.go | 178 ++++++++++++------ client/go/outline/vpn/vpn_linux.go | 86 +-------- client/go/outline/vpn/vpn_others.go | 2 +- 12 files changed, 337 insertions(+), 247 deletions(-) create mode 100644 client/go/outline/vpn.go create mode 100644 client/go/outline/vpn/dialer.go rename client/go/outline/{client_linux.go => vpn/dialer_linux.go} (59%) rename client/go/outline/vpn/{method_channel.go => dialer_others.go} (68%) diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 6c8f65c8f2..a43068a5d7 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -19,7 +19,7 @@ import { } from '../src/www/app/outline_server_repository/vpn'; // TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share. -interface EstablishVpnRequest { +interface VpnConfig { id: string; interfaceName: string; connectionName: string; @@ -28,6 +28,10 @@ interface EstablishVpnRequest { routingTableId: number; routingPriority: number; protectionMark: number; +} + +interface EstablishVpnRequest { + vpn: VpnConfig; transport: string; } @@ -38,28 +42,30 @@ export async function establishVpn(request: StartRequestJson) { statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); const config: EstablishVpnRequest = { - id: currentRequestId, - - // TUN device name, being compatible with old code: - // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203 - interfaceName: 'outline-tun0', - - // Network Manager connection name, Use "TUN Connection" instead of "VPN Connection" - // because Network Manager has a dedicated "VPN Connection" concept that we did not implement - connectionName: 'Outline TUN Connection', - - // TUN IP, being compatible with old code: - // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204 - ipAddress: '10.0.85.1', - - // DNS server list, being compatible with old code: - // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207 - dnsServers: ['9.9.9.9'], - - // Outline magic numbers, 7113 and 0x711E visually resembles "T L I E" in "ouTLInE" - routingTableId: 7113, - routingPriority: 0x711e, - protectionMark: 0x711e, + vpn: { + id: currentRequestId, + + // TUN device name, being compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203 + interfaceName: 'outline-tun0', + + // Network Manager connection name, Use "TUN Connection" instead of "VPN Connection" + // because Network Manager has a dedicated "VPN Connection" concept that we did not implement + connectionName: 'Outline TUN Connection', + + // TUN IP, being compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204 + ipAddress: '10.0.85.1', + + // DNS server list, being compatible with old code: + // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207 + dnsServers: ['9.9.9.9'], + + // Outline magic numbers, 7113 and 0x711E visually resembles "T L I E" in "ouTLInE" + routingTableId: 7113, + routingPriority: 0x711e, + protectionMark: 0x711e, + }, // The actual transport config transport: JSON.stringify(request.config.transport), diff --git a/client/go/outline/device.go b/client/go/outline/device.go index 29e03d16d4..427ed3e063 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -15,9 +15,12 @@ package outline import ( + "context" + "errors" "log/slog" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" "github.com/Jigsaw-Code/outline-sdk/network" "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" @@ -28,26 +31,29 @@ type Device struct { c *Client pkt network.DelegatePacketProxy - supportsUDP *bool + supportsUDP bool remote, fallback network.PacketProxy } -type LinuxOptions struct { - FWMark uint32 +var _ vpn.ProxyDevice = (*Device)(nil) + +func NewDevice(c *Client) (*Device, error) { + if c == nil { + return nil, errors.New("Client must be provided") + } + return &Device{c: c}, nil } -type DeviceOptions struct { - LinuxOpts *LinuxOptions +func (d *Device) SupportsUDP() bool { + return d.supportsUDP } -func NewDevice(c *Client) *Device { - return &Device{ - c: c, +func (d *Device) Connect(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} } -} -func (d *Device) Connect() (err error) { d.remote, err = network.NewPacketProxyFromPacketListener(d.c.PacketListener) if err != nil { return errSetupHandler("failed to create datagram handler", err) @@ -59,7 +65,7 @@ func (d *Device) Connect() (err error) { } slog.Debug("[Outline] local DNS-fallback UDP handler created") - if err = d.RefreshConnectivity(); err != nil { + if err = d.RefreshConnectivity(ctx); err != nil { return } @@ -69,7 +75,6 @@ func (d *Device) Connect() (err error) { } slog.Debug("[Outline] lwIP network stack configured") - slog.Info("[Outline] successfully connected to Outline server") return nil } @@ -77,11 +82,14 @@ func (d *Device) Close() (err error) { if d.IPDevice != nil { err = d.IPDevice.Close() } - slog.Info("[Outline] successfully disconnected from Outline server") return } -func (d *Device) RefreshConnectivity() (err error) { +func (d *Device) RefreshConnectivity(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} + } + slog.Debug("[Outine] Testing connectivity of Outline server ...") result := CheckTCPAndUDPConnectivity(d.c) if result.TCPError != nil { @@ -90,14 +98,14 @@ func (d *Device) RefreshConnectivity() (err error) { } var proxy network.PacketProxy - canHandleUDP := false + d.supportsUDP = false if result.UDPError != nil { slog.Warn("[Outline] server cannot handle UDP traffic", "err", result.UDPError) proxy = d.fallback } else { slog.Debug("[Outline] server can handle UDP traffic") proxy = d.remote - canHandleUDP = true + d.supportsUDP = true } if d.pkt == nil { @@ -109,15 +117,10 @@ func (d *Device) RefreshConnectivity() (err error) { return errSetupHandler("failed to update combined datagram handler", err) } } - d.supportsUDP = &canHandleUDP - slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", canHandleUDP) + slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", d.supportsUDP) return nil } -func (d *Device) SupportsUDP() *bool { - return d.supportsUDP -} - func errSetupHandler(msg string, cause error) error { slog.Error("[Outline] "+msg, "err", cause) return perrs.PlatformError{ diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index 9f2ecd1df5..ea66d07d87 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -39,26 +39,8 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" ) -// init initializes the backend module. -// It sets up a default logger based on the OUTLINE_DEBUG environment variable. -func init() { - opts := slog.HandlerOptions{Level: slog.LevelInfo} - - dbg := os.Getenv("OUTLINE_DEBUG") - if dbg != "" && dbg != "false" && dbg != "0" { - opts.Level = slog.LevelDebug - } - - logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) - slog.SetDefault(logger) - - // Register VPN handlers for desktop environments - vpn.RegisterMethodHandlers() -} - // InvokeMethod is the unified entry point for TypeScript to invoke various Go functions. // // The input and output are all defined as string, but they may represent either a raw string, @@ -109,3 +91,17 @@ func marshalCGoErrorJson(e *platerrors.PlatformError) *C.char { } return newCGoString(json) } + +// init initializes the backend module. +// It sets up a default logger based on the OUTLINE_DEBUG environment variable. +func init() { + opts := slog.HandlerOptions{Level: slog.LevelInfo} + + dbg := os.Getenv("OUTLINE_DEBUG") + if dbg != "" && dbg != "false" && dbg != "0" { + opts.Level = slog.LevelDebug + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) + slog.SetDefault(logger) +} diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index 61daceaf5c..dfb92b9a98 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -41,20 +41,6 @@ const ( MethodCloseVPN = "CloseVPN" ) -// Handler is an interface that defines a method for handling requests from TypeScript. -type Handler func(string) (string, error) - -// handlers is a map of registered handlers. -var handlers = make(map[string]Handler) - -// RegisterMethodHandler registers a native function handler for the given method. -// -// Instead of having [InvokeMethod] directly depend on other packages, we use dependency inversion -// pattern here. This breaks Go's dependency cycle and makes the code more flexible. -func RegisterMethodHandler(method string, handler Handler) { - handlers[method] = handler -} - // InvokeMethodResult represents the result of an InvokeMethod call. // // We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. @@ -74,15 +60,20 @@ func InvokeMethod(method string, input string) *InvokeMethodResult { Error: platerrors.ToPlatformError(err), } - default: - if h, ok := handlers[method]; ok { - val, err := h(input) - return &InvokeMethodResult{ - Value: val, - Error: platerrors.ToPlatformError(err), - } + case MethodEstablishVPN: + conn, err := establishVPN(input) + return &InvokeMethodResult{ + Value: conn, + Error: platerrors.ToPlatformError(err), + } + + case MethodCloseVPN: + err := closeVPN() + return &InvokeMethodResult{ + Error: platerrors.ToPlatformError(err), } + default: return &InvokeMethodResult{Error: &platerrors.PlatformError{ Code: platerrors.InternalError, Message: fmt.Sprintf("unsupported Go method: %s", method), diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn.go new file mode 100644 index 0000000000..e1f7812d7a --- /dev/null +++ b/client/go/outline/vpn.go @@ -0,0 +1,82 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package outline + +import ( + "encoding/json" + "net" + + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" +) + +type vpnConfigJSON struct { + VPNConfig vpn.Config `json:"vpn"` + TransportConfig string `json:"transport"` +} + +func establishVPN(configStr string) (string, error) { + var conf vpnConfigJSON + if err := json.Unmarshal([]byte(configStr), &conf); err != nil { + return "", perrs.PlatformError{ + Code: perrs.IllegalConfig, + Message: "invalid VPN config format", + Cause: perrs.ToPlatformError(err), + } + } + + // Create Outline Client and Device + tcpControl, err := vpn.TCPDialerControl(&conf.VPNConfig) + if err != nil { + return "", err + } + tcp := net.Dialer{ + Control: tcpControl, + KeepAlive: -1, + } + udpControl, err := vpn.UDPDialerControl(&conf.VPNConfig) + if err != nil { + return "", err + } + udp := net.Dialer{Control: udpControl} + c, err := newClientWithBaseDialers(conf.TransportConfig, tcp, udp) + if err != nil { + return "", err + } + proxy, err := NewDevice(c) + if err != nil { + return "", err + } + + // Establish system VPN to the proxy + conn, err := vpn.EstablishVPN(&conf.VPNConfig, proxy) + if err != nil { + return "", err + } + + connJson, err := json.Marshal(conn) + if err != nil { + return "", perrs.PlatformError{ + Code: perrs.InternalError, + Message: "failed to return VPN connection as JSON", + Cause: perrs.ToPlatformError(err), + } + } + return string(connJson), nil +} + +func closeVPN() error { + return vpn.CloseVPN() +} diff --git a/client/go/outline/vpn/dialer.go b/client/go/outline/vpn/dialer.go new file mode 100644 index 0000000000..98a7040e62 --- /dev/null +++ b/client/go/outline/vpn/dialer.go @@ -0,0 +1,19 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpn + +import "syscall" + +type ControlFn = func(network, address string, c syscall.RawConn) error diff --git a/client/go/outline/client_linux.go b/client/go/outline/vpn/dialer_linux.go similarity index 59% rename from client/go/outline/client_linux.go rename to client/go/outline/vpn/dialer_linux.go index 007bebcf13..a1ad244ec9 100644 --- a/client/go/outline/client_linux.go +++ b/client/go/outline/vpn/dialer_linux.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Outline Authors +// Copyright 2024 The Outline Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,29 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. -package outline +package vpn import ( - "net" + "errors" "syscall" ) -// NewClient creates a new Outline client from a configuration string. -func NewClientWithFWMark(transportConfig string, fwmark uint32) (*Client, error) { - control := func(network, address string, c syscall.RawConn) error { +func TCPDialerControl(conf *Config) (ControlFn, error) { + if conf == nil { + return nil, errors.New("VPN config must be provided") + } + return func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(conf.ProtectionMark)) }) - } - - tcp := net.Dialer{ - Control: control, - KeepAlive: -1, - } - - udp := net.Dialer{ - Control: control, - } + }, nil +} - return newClientWithBaseDialers(transportConfig, tcp, udp) +func UDPDialerControl(conf *Config) (ControlFn, error) { + return TCPDialerControl(conf) } diff --git a/client/go/outline/vpn/method_channel.go b/client/go/outline/vpn/dialer_others.go similarity index 68% rename from client/go/outline/vpn/method_channel.go rename to client/go/outline/vpn/dialer_others.go index 13fb101939..8dd1fd3ad9 100644 --- a/client/go/outline/vpn/method_channel.go +++ b/client/go/outline/vpn/dialer_others.go @@ -12,13 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !linux + package vpn -import "github.com/Jigsaw-Code/outline-apps/client/go/outline" +import "errors" + +func TCPDialerControl(conf *Config) (ControlFn, error) { + return nil, errors.ErrUnsupported +} -func RegisterMethodHandlers() { - outline.RegisterMethodHandler(outline.MethodEstablishVPN, EstablishVPN) - outline.RegisterMethodHandler(outline.MethodCloseVPN, func(string) (string, error) { - return "", CloseVPN() - }) +func UDPDialerControl(conf *Config) (ControlFn, error) { + return nil, errors.ErrUnsupported } diff --git a/client/go/outline/vpn/errors.go b/client/go/outline/vpn/errors.go index ae6baaa919..0e4b77ccde 100644 --- a/client/go/outline/vpn/errors.go +++ b/client/go/outline/vpn/errors.go @@ -21,9 +21,10 @@ import ( ) const ( - ioLogPfx = "[IO] " - nmLogPfx = "[NMDBus] " - vpnLogPfx = "[VPN] " + ioLogPfx = "[IO] " + nmLogPfx = "[NMDBus] " + vpnLogPfx = "[VPN] " + proxyLogPfx = "[Proxy] " ) func errIllegalConfig(msg string, params ...any) error { diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index 1ad90c22d1..9c65990d23 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -15,16 +15,16 @@ package vpn import ( - "encoding/json" + "context" + "errors" + "io" "log/slog" "sync" - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" ) -// configJSON represents the JSON structure for setting up a VPN connection. -// This is typically passed from TypeScript. -type configJSON struct { +type Config struct { ID string `json:"id"` InterfaceName string `json:"interfaceName"` IPAddress string `json:"ipAddress"` @@ -33,15 +33,6 @@ type configJSON struct { RoutingTableId uint32 `json:"routingTableId"` RoutingPriority uint32 `json:"routingPriority"` ProtectionMark uint32 `json:"protectionMark"` - TransportConfig string `json:"transport"` -} - -// connectionJSON defines the JSON structure of a [VPNConnection]. -// This is typically returned to TypeScript. -type connectionJSON struct { - ID string `json:"id"` - Status string `json:"status"` - RouteUDP *bool `json:"supportsUDP"` } // Status defines the possible states of a VPN connection. @@ -56,69 +47,114 @@ const ( StatusDisconnecting Status = "Disconnecting" ) +type ProxyDevice interface { + network.IPDevice + Connect(ctx context.Context) error + SupportsUDP() bool + RefreshConnectivity(ctx context.Context) error +} + +type platformVPNConn interface { + Establish(ctx context.Context) error + TUN() io.ReadWriteCloser + Close() error +} + // VPNConnection is a platform neutral interface of a VPN connection. -type VPNConnection interface { - // ID returns the unique identifier of this VPNConnection. - // Typically it is passed in from the TypeScript through configJson. - ID() string +type VPNConnection struct { + ID string `json:"id"` + Status Status `json:"status"` + SupportsUDP *bool `json:"supportsUDP"` - // Status returns the current Status of the VPNConnection. - Status() Status + ctx context.Context + cancel context.CancelFunc + wgEst, wgCopy sync.WaitGroup - // SupportsUDP indicates whether the remote proxy can handle UDP traffic. - // nil means unknown. - SupportsUDP() *bool + proxy ProxyDevice + platform platformVPNConn +} - // Establish tries to connect this VPNConnection. - Establish() error +func (c *VPNConnection) SetStatus(s Status) { + c.Status = s +} - // Close tries to disconnect this VPNConnection. - Close() error +func (c *VPNConnection) SetSupportsUDP(v bool) { + c.SupportsUDP = &v } // The global singleton VPN connection. // This package allows at most one active VPN connection at the same time. var mu sync.Mutex -var conn VPNConnection +var conn *VPNConnection // EstablishVPN establishes a new active [VPNConnection] with the given configuration. // It will first close any active [VPNConnection] using [CloseVPN], and then mark the // newly created [VPNConnection] as the currently active connection. // It returns the connectionJSON as a string, or an error if the connection fails. -func EstablishVPN(configStr string) (_ string, err error) { - var conf configJSON - if err = json.Unmarshal([]byte(configStr), &conf); err != nil { - return "", perrs.PlatformError{ - Code: perrs.IllegalConfig, - Message: "invalid VPN config format", - Cause: perrs.ToPlatformError(err), - } +func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) { + if conf == nil { + return nil, errors.New("a VPN Config must be provided") + } + if proxy == nil { + return nil, errors.New("a proxy device must be provided") } - var c VPNConnection - if c, err = newVPNConnection(&conf); err != nil { + c := &VPNConnection{ + ID: conf.ID, + Status: StatusDisconnected, + } + c.ctx, c.cancel = context.WithCancel(context.Background()) + if c.platform, err = newPlatformVPNConn(conf); err != nil { return } + + c.wgEst.Add(1) + defer c.wgEst.Done() + if err = atomicReplaceVPNConn(c); err != nil { - c.Close() + c.platform.Close() return } - slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID()) - if err = c.Establish(); err != nil { - // No need to call c.Close() cuz it's tracked in the global conn already + + slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID) + + c.SetStatus(StatusConnecting) + defer func() { + if err == nil { + c.SetStatus(StatusConnected) + } else { + c.SetStatus(StatusUnknown) + } + }() + + if err = c.proxy.Connect(c.ctx); err != nil { + slog.Error(proxyLogPfx+"Failed to connect to the proxy", "err", err) return } - slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID()) - - connJson, err := json.Marshal(connectionJSON{c.ID(), string(c.Status()), c.SupportsUDP()}) - if err != nil { - return "", perrs.PlatformError{ - Code: perrs.InternalError, - Message: "failed to return VPN connection as JSON", - Cause: perrs.ToPlatformError(err), - } + slog.Info(proxyLogPfx + "Connected to the proxy") + c.SetSupportsUDP(c.proxy.SupportsUDP()) + + if err = c.platform.Establish(c.ctx); err != nil { + // No need to call c.platform.Close() cuz it's already tracked in the global conn + return } - return string(connJson), nil + + c.wgCopy.Add(2) + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") + n, err := io.Copy(c.proxy, c.platform.TUN()) + slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) + }() + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") + n, err := io.Copy(c.platform.TUN(), c.proxy) + slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) + }() + + slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID) + return c, nil } // CloseVPN closes the currently active [VPNConnection]. @@ -129,15 +165,15 @@ func CloseVPN() error { } // atomicReplaceVPNConn atomically replaces the global conn with newConn. -func atomicReplaceVPNConn(newConn VPNConnection) error { +func atomicReplaceVPNConn(newConn *VPNConnection) error { mu.Lock() defer mu.Unlock() - slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID()) + slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID) if err := closeVPNNoLock(); err != nil { return err } conn = newConn - slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID()) + slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID) return nil } @@ -147,10 +183,36 @@ func closeVPNNoLock() (err error) { if conn == nil { return nil } - slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID()) - if err = conn.Close(); err == nil { - slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID()) + + conn.SetStatus(StatusDisconnecting) + defer func() { + if err == nil { + conn.SetStatus(StatusDisconnected) + } else { + conn.SetStatus(StatusUnknown) + } + }() + + slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID) + + // Cancel the Establish process and wait + conn.cancel() + conn.wgEst.Wait() + + if err = conn.platform.Close(); err == nil { + slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID) conn = nil } + + // We can ignore the following error + if err2 := conn.proxy.Close(); err2 != nil { + slog.Warn(proxyLogPfx + "Failed to disconnect from the proxy") + } else { + slog.Info(proxyLogPfx + "Disconnected from the proxy") + } + + // Wait for traffic copy go routines to finish + conn.wgCopy.Wait() + return } diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index f67d00e4e1..28953cab39 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -19,39 +19,26 @@ import ( "io" "log/slog" "net" - "sync" - "github.com/Jigsaw-Code/outline-apps/client/go/outline" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" gonm "github.com/Wifx/gonetworkmanager/v2" ) // linuxVPNConn implements a [VPNConnection] on Linux platform. type linuxVPNConn struct { - id string - status Status - - ctx context.Context - cancel context.CancelFunc - wgEst, wgCopy sync.WaitGroup - - tun io.ReadWriteCloser - proxy *outline.Device - + tun io.ReadWriteCloser nmOpts *nmConnectionOptions nm gonm.NetworkManager ac gonm.ActiveConnection } -var _ VPNConnection = (*linuxVPNConn)(nil) +var _ platformVPNConn = (*linuxVPNConn)(nil) // newVPNConnection creates a new Linux specific [VPNConnection]. // The newly connection will be [StatusDisconnected] initially, you need to call the // Establish() in order to make it [StatusConnected]. -func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { +func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { c := &linuxVPNConn{ - id: conf.ID, - status: StatusDisconnected, nmOpts: &nmConnectionOptions{ Name: conf.ConnectionName, TUNName: conf.InterfaceName, @@ -79,66 +66,26 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { } c.nmOpts.DNSServers4 = append(c.nmOpts.DNSServers4, dnsIP) } - if conf.TransportConfig == "" { - return nil, errIllegalConfig("must provide a transport config") - } - oc, err := outline.NewClientWithFWMark(conf.TransportConfig, c.nmOpts.FWMark) - if err != nil { - return nil, err - } - c.proxy = outline.NewDevice(oc) - - c.ctx, c.cancel = context.WithCancel(context.Background()) return c, nil } -func (c *linuxVPNConn) ID() string { return c.id } -func (c *linuxVPNConn) Status() Status { return c.status } -func (c *linuxVPNConn) SupportsUDP() *bool { return c.proxy.SupportsUDP() } +func (c *linuxVPNConn) TUN() io.ReadWriteCloser { return c.tun } // Establish tries to establish this [VPNConnection], and makes it [StatusConnected]. -func (c *linuxVPNConn) Establish() (err error) { - c.wgEst.Add(1) - defer c.wgEst.Done() - if c.ctx.Err() != nil { - return &perrs.PlatformError{Code: perrs.OperationCanceled} +func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} } - c.status = StatusConnecting - defer func() { - if err == nil { - c.status = StatusConnected - } else { - c.status = StatusUnknown - } - }() - - if err = c.proxy.Connect(); err != nil { - return - } - if c.tun, err = newTUNDevice(c.nmOpts.TUNName); err != nil { + if c.tun, err = newTUNDevice(c.nmOpts.Name); err != nil { return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.Name) } slog.Info(vpnLogPfx+"TUN device created", "name", c.nmOpts.TUNName) + if err = c.establishNMConnection(); err != nil { return } - - c.wgCopy.Add(2) - go func() { - defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") - n, err := io.Copy(c.proxy, c.tun) - slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) - }() - go func() { - defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") - n, err := io.Copy(c.tun, c.proxy) - slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) - }() - return nil } @@ -148,18 +95,6 @@ func (c *linuxVPNConn) Close() (err error) { return nil } - c.status = StatusDisconnecting - defer func() { - if err == nil { - c.status = StatusDisconnected - } else { - c.status = StatusUnknown - } - }() - - c.cancel() - c.wgEst.Wait() - c.closeNMConnection() if c.tun != nil { // this is the only error that matters @@ -169,9 +104,6 @@ func (c *linuxVPNConn) Close() (err error) { slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) } } - c.proxy.Close() - // Wait for traffic copy go routines to finish - c.wgCopy.Wait() return } diff --git a/client/go/outline/vpn/vpn_others.go b/client/go/outline/vpn/vpn_others.go index 016f406198..30086fd2a9 100644 --- a/client/go/outline/vpn/vpn_others.go +++ b/client/go/outline/vpn/vpn_others.go @@ -18,6 +18,6 @@ package vpn import "errors" -func newVPNConnection(conf *configJSON) (VPNConnection, error) { +func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { return nil, errors.ErrUnsupported } From b05140d7788b09ebdf881754504f2f630dd0aa74 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 16 Dec 2024 16:44:22 -0500 Subject: [PATCH 21/27] Update comments for VPN package --- client/go/outline/device.go | 8 ++++++++ client/go/outline/vpn.go | 7 +++++++ client/go/outline/vpn/dialer.go | 1 + client/go/outline/vpn/dialer_linux.go | 4 ++++ client/go/outline/vpn/dialer_others.go | 2 ++ client/go/outline/vpn/vpn.go | 28 ++++++++++++++++++++------ client/go/outline/vpn/vpn_linux.go | 12 +++++------ 7 files changed, 50 insertions(+), 12 deletions(-) diff --git a/client/go/outline/device.go b/client/go/outline/device.go index 427ed3e063..cee9684b7d 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -26,6 +26,8 @@ import ( "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" ) +// Device is an IPDevice that connects to a remote Outline server. +// It also implements the vpn.ProxyDevice interface. type Device struct { network.IPDevice @@ -38,6 +40,7 @@ type Device struct { var _ vpn.ProxyDevice = (*Device)(nil) +// NewDevice creates a new [Device] using the given [Client]. func NewDevice(c *Client) (*Device, error) { if c == nil { return nil, errors.New("Client must be provided") @@ -45,10 +48,13 @@ func NewDevice(c *Client) (*Device, error) { return &Device{c: c}, nil } +// SupportsUDP returns true if the the Outline server forwards UDP traffic. +// This value will be refreshed after Connect or RefreshConnectivity. func (d *Device) SupportsUDP() bool { return d.supportsUDP } +// Connect tries to connect to the Outline server. func (d *Device) Connect(ctx context.Context) (err error) { if ctx.Err() != nil { return perrs.PlatformError{Code: perrs.OperationCanceled} @@ -78,6 +84,7 @@ func (d *Device) Connect(ctx context.Context) (err error) { return nil } +// Close closes the connection to the Outline server. func (d *Device) Close() (err error) { if d.IPDevice != nil { err = d.IPDevice.Close() @@ -85,6 +92,7 @@ func (d *Device) Close() (err error) { return } +// RefreshConnectivity refreshes the connectivity to the Outline server. func (d *Device) RefreshConnectivity(ctx context.Context) (err error) { if ctx.Err() != nil { return perrs.PlatformError{Code: perrs.OperationCanceled} diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn.go index e1f7812d7a..14339665fb 100644 --- a/client/go/outline/vpn.go +++ b/client/go/outline/vpn.go @@ -27,6 +27,12 @@ type vpnConfigJSON struct { TransportConfig string `json:"transport"` } +// establishVPN establishes a VPN connection using the given configuration string. +// The configuration string should be a JSON object containing the VPN configuration +// and the transport configuration. +// +// The function returns a JSON string representing the established VPN connection, +// or an error if the connection fails. func establishVPN(configStr string) (string, error) { var conf vpnConfigJSON if err := json.Unmarshal([]byte(configStr), &conf); err != nil { @@ -77,6 +83,7 @@ func establishVPN(configStr string) (string, error) { return string(connJson), nil } +// closeVPN closes the currently active VPN connection. func closeVPN() error { return vpn.CloseVPN() } diff --git a/client/go/outline/vpn/dialer.go b/client/go/outline/vpn/dialer.go index 98a7040e62..63782f4b98 100644 --- a/client/go/outline/vpn/dialer.go +++ b/client/go/outline/vpn/dialer.go @@ -16,4 +16,5 @@ package vpn import "syscall" +// ControlFn is an alias to a function type that can be used in net.Dialer.Control. type ControlFn = func(network, address string, c syscall.RawConn) error diff --git a/client/go/outline/vpn/dialer_linux.go b/client/go/outline/vpn/dialer_linux.go index a1ad244ec9..d286419988 100644 --- a/client/go/outline/vpn/dialer_linux.go +++ b/client/go/outline/vpn/dialer_linux.go @@ -19,6 +19,8 @@ import ( "syscall" ) +// TCPDialerControl returns a ControlFn that sets the SO_MARK socket option on a TCP connection. +// This is used to exclude traffic targeting the remote proxy server from routing to TUN device. func TCPDialerControl(conf *Config) (ControlFn, error) { if conf == nil { return nil, errors.New("VPN config must be provided") @@ -30,6 +32,8 @@ func TCPDialerControl(conf *Config) (ControlFn, error) { }, nil } +// TCPDialerControl returns a ControlFn that sets the SO_MARK socket option on a TCP connection. +// This is used to exclude traffic targeting the remote proxy server from routing to TUN device. func UDPDialerControl(conf *Config) (ControlFn, error) { return TCPDialerControl(conf) } diff --git a/client/go/outline/vpn/dialer_others.go b/client/go/outline/vpn/dialer_others.go index 8dd1fd3ad9..e8a3a6b5df 100644 --- a/client/go/outline/vpn/dialer_others.go +++ b/client/go/outline/vpn/dialer_others.go @@ -18,10 +18,12 @@ package vpn import "errors" +// TCPDialerControl is not supported on this platform. func TCPDialerControl(conf *Config) (ControlFn, error) { return nil, errors.ErrUnsupported } +// UDPDialerControl is not supported on this platform. func UDPDialerControl(conf *Config) (ControlFn, error) { return nil, errors.ErrUnsupported } diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index 9c65990d23..3c77d1ba92 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -24,6 +24,7 @@ import ( "github.com/Jigsaw-Code/outline-sdk/network" ) +// Config holds the configuration to establish a system-wide [VPNConnection]. type Config struct { ID string `json:"id"` InterfaceName string `json:"interfaceName"` @@ -35,7 +36,7 @@ type Config struct { ProtectionMark uint32 `json:"protectionMark"` } -// Status defines the possible states of a VPN connection. +// Status defines the possible states of a [VPNConnection]. type Status string // Constants representing the different VPN connection statuses. @@ -47,20 +48,33 @@ const ( StatusDisconnecting Status = "Disconnecting" ) +// ProxyDevice is an interface representing a remote proxy server device. type ProxyDevice interface { network.IPDevice + + // Connect establishes a connection to the proxy device. Connect(ctx context.Context) error + + // SupportsUDP returns true if the proxy device is able to handle UDP traffic. SupportsUDP() bool + + // RefreshConnectivity refreshes the UDP support of the proxy device. RefreshConnectivity(ctx context.Context) error } +// platformVPNConn is an interface representing an OS-specific VPN connection. type platformVPNConn interface { + // Establish creates a TUN device and routes all system traffic to it. Establish(ctx context.Context) error + + // TUN returns a L3 IP tun device associated with the VPN connection. TUN() io.ReadWriteCloser + + // Close terminates the VPN connection and closes the TUN device. Close() error } -// VPNConnection is a platform neutral interface of a VPN connection. +// VPNConnection represents a system-wide VPN connection. type VPNConnection struct { ID string `json:"id"` Status Status `json:"status"` @@ -74,10 +88,12 @@ type VPNConnection struct { platform platformVPNConn } +// SetStatus sets the status of the VPN connection. func (c *VPNConnection) SetStatus(s Status) { c.Status = s } +// SetSupportsUDP sets whether the VPN connection supports UDP. func (c *VPNConnection) SetSupportsUDP(v bool) { c.SupportsUDP = &v } @@ -87,10 +103,10 @@ func (c *VPNConnection) SetSupportsUDP(v bool) { var mu sync.Mutex var conn *VPNConnection -// EstablishVPN establishes a new active [VPNConnection] with the given configuration. -// It will first close any active [VPNConnection] using [CloseVPN], and then mark the +// EstablishVPN establishes a new active [VPNConnection] with the given [Config]. +// It first closes any active [VPNConnection] using [CloseVPN], and then marks the // newly created [VPNConnection] as the currently active connection. -// It returns the connectionJSON as a string, or an error if the connection fails. +// It returns the new [VPNConnection], or an error if the connection fails. func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) { if conf == nil { return nil, errors.New("a VPN Config must be provided") @@ -157,7 +173,7 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) return c, nil } -// CloseVPN closes the currently active [VPNConnection]. +// CloseVPN terminates the currently active [VPNConnection]. func CloseVPN() error { mu.Lock() defer mu.Unlock() diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index 28953cab39..45b8c13108 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -24,7 +24,7 @@ import ( gonm "github.com/Wifx/gonetworkmanager/v2" ) -// linuxVPNConn implements a [VPNConnection] on Linux platform. +// linuxVPNConn implements a platformVPNConn on the Linux platform. type linuxVPNConn struct { tun io.ReadWriteCloser nmOpts *nmConnectionOptions @@ -34,9 +34,8 @@ type linuxVPNConn struct { var _ platformVPNConn = (*linuxVPNConn)(nil) -// newVPNConnection creates a new Linux specific [VPNConnection]. -// The newly connection will be [StatusDisconnected] initially, you need to call the -// Establish() in order to make it [StatusConnected]. +// newPlatformVPNConn creates a new Linux-specific platformVPNConn. +// You need to call Establish() in order to make it connected. func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { c := &linuxVPNConn{ nmOpts: &nmConnectionOptions{ @@ -70,9 +69,10 @@ func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { return c, nil } +// TUN returns the Linux L3 TUN device. func (c *linuxVPNConn) TUN() io.ReadWriteCloser { return c.tun } -// Establish tries to establish this [VPNConnection], and makes it [StatusConnected]. +// Establish tries to create the TUN device and route all traffic to it. func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { if ctx.Err() != nil { return perrs.PlatformError{Code: perrs.OperationCanceled} @@ -89,7 +89,7 @@ func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { return nil } -// Close tries to close this [VPNConnection] and make it [StatusDisconnected]. +// Close tries to restore the routing and deletes the TUN device. func (c *linuxVPNConn) Close() (err error) { if c == nil { return nil From c77e7a7b4003fb3002a8a6bc12d7d6d4f045daaf Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 16 Dec 2024 17:21:17 -0500 Subject: [PATCH 22/27] extract nm connection code out of the linuxVPNConn --- client/go/outline/device.go | 2 +- client/go/outline/vpn/nmconn_linux.go | 64 +++++++++++++-------------- client/go/outline/vpn/vpn.go | 32 +++++++------- client/go/outline/vpn/vpn_linux.go | 12 +++-- 4 files changed, 59 insertions(+), 51 deletions(-) diff --git a/client/go/outline/device.go b/client/go/outline/device.go index cee9684b7d..8c60d8a7a1 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -98,7 +98,7 @@ func (d *Device) RefreshConnectivity(ctx context.Context) (err error) { return perrs.PlatformError{Code: perrs.OperationCanceled} } - slog.Debug("[Outine] Testing connectivity of Outline server ...") + slog.Debug("[Outline] Testing connectivity of Outline server ...") result := CheckTCPAndUDPConnectivity(d.c) if result.TCPError != nil { slog.Warn("[Outline] Outline server connectivity test failed", "err", result.TCPError) diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index f5668577a2..8c3e153781 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -16,6 +16,7 @@ package vpn import ( "encoding/binary" + "errors" "log/slog" "net" "time" @@ -34,39 +35,38 @@ type nmConnectionOptions struct { RoutingPriority uint32 } -func (c *linuxVPNConn) establishNMConnection() (err error) { +func establishNMConnection(nm gonm.NetworkManager, opts *nmConnectionOptions) (ac gonm.ActiveConnection, err error) { + if nm == nil { + return nil, errors.New("must provide a NetworkManager") + } defer func() { if err != nil { - c.closeNMConnection() + closeNMConnection(nm, ac) + ac = nil } }() - if c.nm, err = gonm.NewNetworkManager(); err != nil { - return errSetupVPN(nmLogPfx, "failed to connect", err) - } - slog.Debug(nmLogPfx + "connected") - - dev, err := waitForTUNDeviceToBeAvailable(c.nm, c.nmOpts.TUNName) + dev, err := waitForTUNDeviceToBeAvailable(nm, opts.TUNName) if err != nil { - return errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", c.nmOpts.TUNName) + return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", opts.TUNName) } - slog.Debug(nmLogPfx+"located TUN device", "tun", c.nmOpts.TUNName, "dev", dev.GetPath()) + slog.Debug(nmLogPfx+"located TUN device", "tun", opts.TUNName, "dev", dev.GetPath()) if err = dev.SetPropertyManaged(true); err != nil { - return errSetupVPN(nmLogPfx, "failed to set TUN device to be managed", err, "dev", dev.GetPath()) + return nil, errSetupVPN(nmLogPfx, "failed to set TUN device to be managed", err, "dev", dev.GetPath()) } slog.Debug(nmLogPfx+"set TUN device to be managed", "dev", dev.GetPath()) props := make(map[string]map[string]interface{}) - configureCommonProps(props, c.nmOpts) + configureCommonProps(props, opts) configureTUNProps(props) - configureIPv4Props(props, c.nmOpts) + configureIPv4Props(props, opts) slog.Debug(nmLogPfx+"populated NetworkManager connection settings", "settings", props) // The previous SetPropertyManaged call needs some time to take effect (typically within 50ms) for retries := 20; retries > 0; retries-- { slog.Debug(nmLogPfx+"trying to create connection for TUN device ...", "dev", dev.GetPath()) - c.ac, err = c.nm.AddAndActivateConnection(props, dev) + ac, err = nm.AddAndActivateConnection(props, dev) if err == nil { break } @@ -74,32 +74,32 @@ func (c *linuxVPNConn) establishNMConnection() (err error) { time.Sleep(50 * time.Millisecond) } if err != nil { - return errSetupVPN(nmLogPfx, "failed to create new connection for device", err, "dev", dev.GetPath()) + return ac, errSetupVPN(nmLogPfx, "failed to create new connection for device", err, "dev", dev.GetPath()) } - slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) - return nil + return } -func (c *linuxVPNConn) closeNMConnection() error { - if c == nil || c.nm == nil { +func closeNMConnection(nm gonm.NetworkManager, ac gonm.ActiveConnection) error { + if nm == nil { + return errors.New("must provide a NetworkManager") + } + if ac == nil { return nil } - if c.ac != nil { - if err := c.nm.DeactivateConnection(c.ac); err != nil { - slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", c.ac.GetPath()) - } - slog.Debug(nmLogPfx+"deactivated connection", "conn", c.ac.GetPath()) + if err := nm.DeactivateConnection(ac); err != nil { + slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", ac.GetPath()) + } + slog.Debug(nmLogPfx+"deactivated connection", "conn", ac.GetPath()) - conn, err := c.ac.GetPropertyConnection() - if err == nil { - err = conn.Delete() - } - if err != nil { - return errCloseVPN(nmLogPfx, "failed to delete connection", err, "conn", c.ac.GetPath()) - } - slog.Info(nmLogPfx+"connection deleted", "conn", c.ac.GetPath()) + conn, err := ac.GetPropertyConnection() + if err == nil { + err = conn.Delete() + } + if err != nil { + return errCloseVPN(nmLogPfx, "failed to delete connection", err, "conn", ac.GetPath()) } + slog.Info(nmLogPfx+"connection deleted", "conn", ac.GetPath()) return nil } diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index 3c77d1ba92..ed7d9d2707 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -103,7 +103,8 @@ func (c *VPNConnection) SetSupportsUDP(v bool) { var mu sync.Mutex var conn *VPNConnection -// EstablishVPN establishes a new active [VPNConnection] with the given [Config]. +// EstablishVPN establishes a new active [VPNConnection] connecting to a [ProxyDevice] +// with the given VPN [Config]. // It first closes any active [VPNConnection] using [CloseVPN], and then marks the // newly created [VPNConnection] as the currently active connection. // It returns the new [VPNConnection], or an error if the connection fails. @@ -118,6 +119,7 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) c := &VPNConnection{ ID: conf.ID, Status: StatusDisconnected, + proxy: proxy, } c.ctx, c.cancel = context.WithCancel(context.Background()) if c.platform, err = newPlatformVPNConn(conf); err != nil { @@ -132,7 +134,7 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) return } - slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID) + slog.Debug(vpnLogPfx+"establishing VPN connection ...", "id", c.ID) c.SetStatus(StatusConnecting) defer func() { @@ -144,10 +146,10 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) }() if err = c.proxy.Connect(c.ctx); err != nil { - slog.Error(proxyLogPfx+"Failed to connect to the proxy", "err", err) + slog.Error(proxyLogPfx+"failed to connect to the proxy", "err", err) return } - slog.Info(proxyLogPfx + "Connected to the proxy") + slog.Info(proxyLogPfx + "connected to the proxy") c.SetSupportsUDP(c.proxy.SupportsUDP()) if err = c.platform.Establish(c.ctx); err != nil { @@ -158,13 +160,13 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) c.wgCopy.Add(2) go func() { defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") + slog.Debug(ioLogPfx + "copying traffic from TUN Device -> OutlineDevice...") n, err := io.Copy(c.proxy, c.platform.TUN()) slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) }() go func() { defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") + slog.Debug(ioLogPfx + "copying traffic from OutlineDevice -> TUN Device...") n, err := io.Copy(c.platform.TUN(), c.proxy) slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) }() @@ -173,7 +175,7 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) return c, nil } -// CloseVPN terminates the currently active [VPNConnection]. +// CloseVPN terminates the currently active [VPNConnection] and disconnects the proxy. func CloseVPN() error { mu.Lock() defer mu.Unlock() @@ -184,7 +186,7 @@ func CloseVPN() error { func atomicReplaceVPNConn(newConn *VPNConnection) error { mu.Lock() defer mu.Unlock() - slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID) + slog.Debug(vpnLogPfx+"creating VPN Connection ...", "id", newConn.ID) if err := closeVPNNoLock(); err != nil { return err } @@ -203,28 +205,28 @@ func closeVPNNoLock() (err error) { conn.SetStatus(StatusDisconnecting) defer func() { if err == nil { + slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID) conn.SetStatus(StatusDisconnected) + conn = nil } else { conn.SetStatus(StatusUnknown) } }() - slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID) + slog.Debug(vpnLogPfx+"closing existing VPN Connection ...", "id", conn.ID) // Cancel the Establish process and wait conn.cancel() conn.wgEst.Wait() - if err = conn.platform.Close(); err == nil { - slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID) - conn = nil - } + // This is the only error that matters + err = conn.platform.Close() // We can ignore the following error if err2 := conn.proxy.Close(); err2 != nil { - slog.Warn(proxyLogPfx + "Failed to disconnect from the proxy") + slog.Warn(proxyLogPfx + "failed to disconnect from the proxy") } else { - slog.Info(proxyLogPfx + "Disconnected from the proxy") + slog.Info(proxyLogPfx + "disconnected from the proxy") } // Wait for traffic copy go routines to finish diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index 45b8c13108..9701d2378c 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -66,6 +66,11 @@ func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { c.nmOpts.DNSServers4 = append(c.nmOpts.DNSServers4, dnsIP) } + if c.nm, err = gonm.NewNetworkManager(); err != nil { + return nil, errSetupVPN(nmLogPfx, "failed to connect", err) + } + slog.Debug(nmLogPfx + "connected") + return c, nil } @@ -78,14 +83,15 @@ func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { return perrs.PlatformError{Code: perrs.OperationCanceled} } - if c.tun, err = newTUNDevice(c.nmOpts.Name); err != nil { + if c.tun, err = newTUNDevice(c.nmOpts.TUNName); err != nil { return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.Name) } slog.Info(vpnLogPfx+"TUN device created", "name", c.nmOpts.TUNName) - if err = c.establishNMConnection(); err != nil { + if c.ac, err = establishNMConnection(c.nm, c.nmOpts); err != nil { return } + slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) return nil } @@ -95,7 +101,7 @@ func (c *linuxVPNConn) Close() (err error) { return nil } - c.closeNMConnection() + closeNMConnection(c.nm, c.ac) if c.tun != nil { // this is the only error that matters if err = c.tun.Close(); err != nil { From 10c8d4aeeba6d571bfcd10c4d09bf23cf2f20652 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Wed, 18 Dec 2024 16:13:46 -0500 Subject: [PATCH 23/27] move dialer control to outline package --- client/go/outline/client.go | 2 +- client/go/outline/{vpn => }/dialer.go | 17 ++++++-- client/go/outline/dialer_linux.go | 45 ++++++++++++++++++++ client/go/outline/{vpn => }/dialer_others.go | 19 +++++---- client/go/outline/vpn.go | 10 +---- client/go/outline/vpn/dialer_linux.go | 39 ----------------- 6 files changed, 72 insertions(+), 60 deletions(-) rename client/go/outline/{vpn => }/dialer.go (67%) create mode 100644 client/go/outline/dialer_linux.go rename client/go/outline/{vpn => }/dialer_others.go (60%) delete mode 100644 client/go/outline/vpn/dialer_linux.go diff --git a/client/go/outline/client.go b/client/go/outline/client.go index cc39396bb0..6c613dbb1f 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,7 +42,7 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - client, err := newClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{}) + client, err := newClientWithBaseDialers(transportConfig, newTCPDialer(), newUDPDialer()) return &NewClientResult{ Client: client, Error: platerrors.ToPlatformError(err), diff --git a/client/go/outline/vpn/dialer.go b/client/go/outline/dialer.go similarity index 67% rename from client/go/outline/vpn/dialer.go rename to client/go/outline/dialer.go index 63782f4b98..0e4091ba55 100644 --- a/client/go/outline/vpn/dialer.go +++ b/client/go/outline/dialer.go @@ -12,9 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -package vpn +package outline -import "syscall" +import ( + "net" +) -// ControlFn is an alias to a function type that can be used in net.Dialer.Control. -type ControlFn = func(network, address string, c syscall.RawConn) error +// newTCPDialer creates a default base TCP dialer for [Client]. +func newTCPDialer() net.Dialer { + return net.Dialer{KeepAlive: -1} +} + +// newUDPDialer creates a default base UDP dialer for [Client]. +func newUDPDialer() net.Dialer { + return net.Dialer{} +} diff --git a/client/go/outline/dialer_linux.go b/client/go/outline/dialer_linux.go new file mode 100644 index 0000000000..98560d2fff --- /dev/null +++ b/client/go/outline/dialer_linux.go @@ -0,0 +1,45 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package outline + +import ( + "net" + "syscall" +) + +// newFWMarkProtectedTCPDialer creates a base TCP dialer for [Client] +// protected by the specified firewall mark. +func newFWMarkProtectedTCPDialer(fwmark uint32) (net.Dialer, error) { + return net.Dialer{ + KeepAlive: -1, + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) + }) + }, + }, nil +} + +// newFWMarkProtectedUDPDialer creates a new UDP dialer for [Client] +// protected by the specified firewall mark. +func newFWMarkProtectedUDPDialer(fwmark uint32) (net.Dialer, error) { + return net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) + }) + }, + }, nil +} diff --git a/client/go/outline/vpn/dialer_others.go b/client/go/outline/dialer_others.go similarity index 60% rename from client/go/outline/vpn/dialer_others.go rename to client/go/outline/dialer_others.go index e8a3a6b5df..a4a76cc0f8 100644 --- a/client/go/outline/vpn/dialer_others.go +++ b/client/go/outline/dialer_others.go @@ -14,16 +14,19 @@ //go:build !linux -package vpn +package outline -import "errors" +import ( + "errors" + "net" +) -// TCPDialerControl is not supported on this platform. -func TCPDialerControl(conf *Config) (ControlFn, error) { - return nil, errors.ErrUnsupported +// newFWMarkProtectedTCPDialer is not supported on non-Linux platforms. +func newFWMarkProtectedTCPDialer(fwmark uint32) (net.Dialer, error) { + return net.Dialer{}, errors.ErrUnsupported } -// UDPDialerControl is not supported on this platform. -func UDPDialerControl(conf *Config) (ControlFn, error) { - return nil, errors.ErrUnsupported +// newFWMarkProtectedUDPDialer is not supported on non-Linux platforms. +func newFWMarkProtectedUDPDialer(fwmark uint32) (net.Dialer, error) { + return net.Dialer{}, errors.ErrUnsupported } diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn.go index 14339665fb..7714d61173 100644 --- a/client/go/outline/vpn.go +++ b/client/go/outline/vpn.go @@ -16,7 +16,6 @@ package outline import ( "encoding/json" - "net" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" @@ -44,19 +43,14 @@ func establishVPN(configStr string) (string, error) { } // Create Outline Client and Device - tcpControl, err := vpn.TCPDialerControl(&conf.VPNConfig) + tcp, err := newFWMarkProtectedTCPDialer(conf.VPNConfig.ProtectionMark) if err != nil { return "", err } - tcp := net.Dialer{ - Control: tcpControl, - KeepAlive: -1, - } - udpControl, err := vpn.UDPDialerControl(&conf.VPNConfig) + udp, err := newFWMarkProtectedUDPDialer(conf.VPNConfig.ProtectionMark) if err != nil { return "", err } - udp := net.Dialer{Control: udpControl} c, err := newClientWithBaseDialers(conf.TransportConfig, tcp, udp) if err != nil { return "", err diff --git a/client/go/outline/vpn/dialer_linux.go b/client/go/outline/vpn/dialer_linux.go deleted file mode 100644 index d286419988..0000000000 --- a/client/go/outline/vpn/dialer_linux.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package vpn - -import ( - "errors" - "syscall" -) - -// TCPDialerControl returns a ControlFn that sets the SO_MARK socket option on a TCP connection. -// This is used to exclude traffic targeting the remote proxy server from routing to TUN device. -func TCPDialerControl(conf *Config) (ControlFn, error) { - if conf == nil { - return nil, errors.New("VPN config must be provided") - } - return func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(conf.ProtectionMark)) - }) - }, nil -} - -// TCPDialerControl returns a ControlFn that sets the SO_MARK socket option on a TCP connection. -// This is used to exclude traffic targeting the remote proxy server from routing to TUN device. -func UDPDialerControl(conf *Config) (ControlFn, error) { - return TCPDialerControl(conf) -} From 1edc3b67d2a2792de88bf2d1cd47ac8672ca2f90 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Thu, 19 Dec 2024 15:57:14 -0500 Subject: [PATCH 24/27] resolve code review comments 3 --- client/go/outline/connectivity.go | 19 +-- .../go/outline/connectivity/connectivity.go | 25 ++++ client/go/outline/device.go | 139 ------------------ client/go/outline/dialer_linux.go | 8 +- client/go/outline/dialer_others.go | 15 +- client/go/outline/method_channel.go | 3 +- client/go/outline/vpn.go | 42 ++---- client/go/outline/vpn/device.go | 131 +++++++++++++++++ client/go/outline/vpn/errors.go | 22 +-- client/go/outline/vpn/nmconn_linux.go | 29 ++-- client/go/outline/vpn/tun_linux.go | 8 +- client/go/outline/vpn/vpn.go | 118 +++++---------- client/go/outline/vpn/vpn_linux.go | 14 +- client/go/outline/vpn/vpn_others.go | 4 +- 14 files changed, 246 insertions(+), 331 deletions(-) delete mode 100644 client/go/outline/device.go create mode 100644 client/go/outline/vpn/device.go diff --git a/client/go/outline/connectivity.go b/client/go/outline/connectivity.go index 088ff4bf14..7241acb6c9 100644 --- a/client/go/outline/connectivity.go +++ b/client/go/outline/connectivity.go @@ -15,18 +15,10 @@ package outline import ( - "net" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/connectivity" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -const ( - tcpTestWebsite = "http://example.com" - dnsServerIP = "1.1.1.1" - dnsServerPort = 53 -) - // TCPAndUDPConnectivityResult represents the result of TCP and UDP connectivity checks. // // We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. @@ -40,16 +32,7 @@ type TCPAndUDPConnectivityResult struct { // containing a TCP error and a UDP error. // If the connectivity check was successful, the corresponding error field will be nil. func CheckTCPAndUDPConnectivity(client *Client) *TCPAndUDPConnectivityResult { - // Start asynchronous UDP support check. - udpErrChan := make(chan error) - go func() { - resolverAddr := &net.UDPAddr{IP: net.ParseIP(dnsServerIP), Port: dnsServerPort} - udpErrChan <- connectivity.CheckUDPConnectivityWithDNS(client, resolverAddr) - }() - - tcpErr := connectivity.CheckTCPConnectivityWithHTTP(client, tcpTestWebsite) - udpErr := <-udpErrChan - + tcpErr, udpErr := connectivity.CheckTCPAndUDPConnectivity(client, client) return &TCPAndUDPConnectivityResult{ TCPError: platerrors.ToPlatformError(tcpErr), UDPError: platerrors.ToPlatformError(udpErr), diff --git a/client/go/outline/connectivity/connectivity.go b/client/go/outline/connectivity/connectivity.go index 2bb3bbb392..51078e04a7 100644 --- a/client/go/outline/connectivity/connectivity.go +++ b/client/go/outline/connectivity/connectivity.go @@ -32,6 +32,31 @@ const ( bufferLength = 512 ) +const ( + testTCPWebsite = "http://example.com" + testDNSServerIP = "1.1.1.1" + testDNSServerPort = 53 +) + +// CheckTCPAndUDPConnectivity checks whether the given `tcp` and `udp` clients can relay traffic. +// +// It parallelizes the execution of TCP and UDP checks, and returns a TCP error and a UDP error. +// A nil error indicates successful connectivity for the corresponding protocol. +func CheckTCPAndUDPConnectivity( + tcp transport.StreamDialer, udp transport.PacketListener, +) (tcpErr error, udpErr error) { + // Start asynchronous UDP support check. + udpErrChan := make(chan error) + go func() { + resolverAddr := &net.UDPAddr{IP: net.ParseIP(testDNSServerIP), Port: testDNSServerPort} + udpErrChan <- CheckUDPConnectivityWithDNS(udp, resolverAddr) + }() + + tcpErr = CheckTCPConnectivityWithHTTP(tcp, testTCPWebsite) + udpErr = <-udpErrChan + return +} + // CheckUDPConnectivityWithDNS determines whether the Outline proxy represented by `client` and // the network support UDP traffic by issuing a DNS query though a resolver at `resolverAddr`. // Returns nil on success or an error on failure. diff --git a/client/go/outline/device.go b/client/go/outline/device.go deleted file mode 100644 index 8c60d8a7a1..0000000000 --- a/client/go/outline/device.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package outline - -import ( - "context" - "errors" - "log/slog" - - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" - "github.com/Jigsaw-Code/outline-sdk/network" - "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" - "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" -) - -// Device is an IPDevice that connects to a remote Outline server. -// It also implements the vpn.ProxyDevice interface. -type Device struct { - network.IPDevice - - c *Client - pkt network.DelegatePacketProxy - supportsUDP bool - - remote, fallback network.PacketProxy -} - -var _ vpn.ProxyDevice = (*Device)(nil) - -// NewDevice creates a new [Device] using the given [Client]. -func NewDevice(c *Client) (*Device, error) { - if c == nil { - return nil, errors.New("Client must be provided") - } - return &Device{c: c}, nil -} - -// SupportsUDP returns true if the the Outline server forwards UDP traffic. -// This value will be refreshed after Connect or RefreshConnectivity. -func (d *Device) SupportsUDP() bool { - return d.supportsUDP -} - -// Connect tries to connect to the Outline server. -func (d *Device) Connect(ctx context.Context) (err error) { - if ctx.Err() != nil { - return perrs.PlatformError{Code: perrs.OperationCanceled} - } - - d.remote, err = network.NewPacketProxyFromPacketListener(d.c.PacketListener) - if err != nil { - return errSetupHandler("failed to create datagram handler", err) - } - slog.Debug("[Outline] remote UDP handler created") - - if d.fallback, err = dnstruncate.NewPacketProxy(); err != nil { - return errSetupHandler("failed to create datagram handler for DNS fallback", err) - } - slog.Debug("[Outline] local DNS-fallback UDP handler created") - - if err = d.RefreshConnectivity(ctx); err != nil { - return - } - - d.IPDevice, err = lwip2transport.ConfigureDevice(d.c.StreamDialer, d.pkt) - if err != nil { - return errSetupHandler("failed to configure Outline network stack", err) - } - slog.Debug("[Outline] lwIP network stack configured") - - return nil -} - -// Close closes the connection to the Outline server. -func (d *Device) Close() (err error) { - if d.IPDevice != nil { - err = d.IPDevice.Close() - } - return -} - -// RefreshConnectivity refreshes the connectivity to the Outline server. -func (d *Device) RefreshConnectivity(ctx context.Context) (err error) { - if ctx.Err() != nil { - return perrs.PlatformError{Code: perrs.OperationCanceled} - } - - slog.Debug("[Outline] Testing connectivity of Outline server ...") - result := CheckTCPAndUDPConnectivity(d.c) - if result.TCPError != nil { - slog.Warn("[Outline] Outline server connectivity test failed", "err", result.TCPError) - return result.TCPError - } - - var proxy network.PacketProxy - d.supportsUDP = false - if result.UDPError != nil { - slog.Warn("[Outline] server cannot handle UDP traffic", "err", result.UDPError) - proxy = d.fallback - } else { - slog.Debug("[Outline] server can handle UDP traffic") - proxy = d.remote - d.supportsUDP = true - } - - if d.pkt == nil { - if d.pkt, err = network.NewDelegatePacketProxy(proxy); err != nil { - return errSetupHandler("failed to create combined datagram handler", err) - } - } else { - if err = d.pkt.SetProxy(proxy); err != nil { - return errSetupHandler("failed to update combined datagram handler", err) - } - } - slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", d.supportsUDP) - return nil -} - -func errSetupHandler(msg string, cause error) error { - slog.Error("[Outline] "+msg, "err", cause) - return perrs.PlatformError{ - Code: perrs.SetupTrafficHandlerFailed, - Message: msg, - Cause: perrs.ToPlatformError(cause), - } -} diff --git a/client/go/outline/dialer_linux.go b/client/go/outline/dialer_linux.go index 98560d2fff..dd2ce58edf 100644 --- a/client/go/outline/dialer_linux.go +++ b/client/go/outline/dialer_linux.go @@ -21,7 +21,7 @@ import ( // newFWMarkProtectedTCPDialer creates a base TCP dialer for [Client] // protected by the specified firewall mark. -func newFWMarkProtectedTCPDialer(fwmark uint32) (net.Dialer, error) { +func newFWMarkProtectedTCPDialer(fwmark uint32) net.Dialer { return net.Dialer{ KeepAlive: -1, Control: func(network, address string, c syscall.RawConn) error { @@ -29,17 +29,17 @@ func newFWMarkProtectedTCPDialer(fwmark uint32) (net.Dialer, error) { syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) }) }, - }, nil + } } // newFWMarkProtectedUDPDialer creates a new UDP dialer for [Client] // protected by the specified firewall mark. -func newFWMarkProtectedUDPDialer(fwmark uint32) (net.Dialer, error) { +func newFWMarkProtectedUDPDialer(fwmark uint32) net.Dialer { return net.Dialer{ Control: func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark)) }) }, - }, nil + } } diff --git a/client/go/outline/dialer_others.go b/client/go/outline/dialer_others.go index a4a76cc0f8..2d03fd7fb8 100644 --- a/client/go/outline/dialer_others.go +++ b/client/go/outline/dialer_others.go @@ -16,17 +16,12 @@ package outline -import ( - "errors" - "net" -) +import "net" -// newFWMarkProtectedTCPDialer is not supported on non-Linux platforms. -func newFWMarkProtectedTCPDialer(fwmark uint32) (net.Dialer, error) { - return net.Dialer{}, errors.ErrUnsupported +func newFWMarkProtectedTCPDialer(fwmark uint32) net.Dialer { + panic("SO_MARK socket option is only supported on Linux") } -// newFWMarkProtectedUDPDialer is not supported on non-Linux platforms. -func newFWMarkProtectedUDPDialer(fwmark uint32) (net.Dialer, error) { - return net.Dialer{}, errors.ErrUnsupported +func newFWMarkProtectedUDPDialer(fwmark uint32) net.Dialer { + panic("SO_MARK socket option is only supported on Linux") } diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index dfb92b9a98..65500ba498 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -61,9 +61,8 @@ func InvokeMethod(method string, input string) *InvokeMethodResult { } case MethodEstablishVPN: - conn, err := establishVPN(input) + err := establishVPN(input) return &InvokeMethodResult{ - Value: conn, Error: platerrors.ToPlatformError(err), } diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn.go index 7714d61173..fb2f29b1f3 100644 --- a/client/go/outline/vpn.go +++ b/client/go/outline/vpn.go @@ -15,6 +15,7 @@ package outline import ( + "context" "encoding/json" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" @@ -30,51 +31,26 @@ type vpnConfigJSON struct { // The configuration string should be a JSON object containing the VPN configuration // and the transport configuration. // -// The function returns a JSON string representing the established VPN connection, -// or an error if the connection fails. -func establishVPN(configStr string) (string, error) { +// The function returns a non-nil error if the connection fails. +func establishVPN(configStr string) error { var conf vpnConfigJSON if err := json.Unmarshal([]byte(configStr), &conf); err != nil { - return "", perrs.PlatformError{ + return perrs.PlatformError{ Code: perrs.IllegalConfig, Message: "invalid VPN config format", Cause: perrs.ToPlatformError(err), } } - // Create Outline Client and Device - tcp, err := newFWMarkProtectedTCPDialer(conf.VPNConfig.ProtectionMark) - if err != nil { - return "", err - } - udp, err := newFWMarkProtectedUDPDialer(conf.VPNConfig.ProtectionMark) - if err != nil { - return "", err - } + tcp := newFWMarkProtectedTCPDialer(conf.VPNConfig.ProtectionMark) + udp := newFWMarkProtectedUDPDialer(conf.VPNConfig.ProtectionMark) c, err := newClientWithBaseDialers(conf.TransportConfig, tcp, udp) if err != nil { - return "", err - } - proxy, err := NewDevice(c) - if err != nil { - return "", err + return err } - // Establish system VPN to the proxy - conn, err := vpn.EstablishVPN(&conf.VPNConfig, proxy) - if err != nil { - return "", err - } - - connJson, err := json.Marshal(conn) - if err != nil { - return "", perrs.PlatformError{ - Code: perrs.InternalError, - Message: "failed to return VPN connection as JSON", - Cause: perrs.ToPlatformError(err), - } - } - return string(connJson), nil + _, err = vpn.EstablishVPN(context.Background(), &conf.VPNConfig, c, c) + return err } // closeVPN closes the currently active VPN connection. diff --git a/client/go/outline/vpn/device.go b/client/go/outline/vpn/device.go new file mode 100644 index 0000000000..daef6b9afb --- /dev/null +++ b/client/go/outline/vpn/device.go @@ -0,0 +1,131 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vpn + +import ( + "context" + "errors" + "log/slog" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/connectivity" + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" + "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" + "github.com/Jigsaw-Code/outline-sdk/transport" +) + +// RemoteDevice is an IPDevice that connects to a remote Outline server. +type RemoteDevice struct { + network.IPDevice + + sd transport.StreamDialer + pl transport.PacketListener + + pkt network.DelegatePacketProxy + remote, fallback network.PacketProxy +} + +func ConnectRemoteDevice( + ctx context.Context, sd transport.StreamDialer, pl transport.PacketListener, +) (_ *RemoteDevice, err error) { + if sd == nil { + return nil, errors.New("StreamDialer must be provided") + } + if pl == nil { + return nil, errors.New("PacketListener must be provided") + } + if ctx.Err() != nil { + return nil, errCancelled(ctx.Err()) + } + + dev := &RemoteDevice{sd: sd, pl: pl} + + dev.remote, err = network.NewPacketProxyFromPacketListener(pl) + if err != nil { + return nil, errSetupHandler("failed to create remote UDP handler", err) + } + slog.Debug("remote device remote UDP handler created") + + if dev.fallback, err = dnstruncate.NewPacketProxy(); err != nil { + return nil, errSetupHandler("failed to create UDP handler for DNS-fallback", err) + } + slog.Debug("remote device local DNS-fallback UDP handler created") + + if err = dev.RefreshConnectivity(ctx); err != nil { + return + } + + dev.IPDevice, err = lwip2transport.ConfigureDevice(sd, dev.pkt) + if err != nil { + return nil, errSetupHandler("remote device failed to configure network stack", err) + } + slog.Debug("remote device lwIP network stack configured") + + return dev, nil +} + +// Close closes the connection to the Outline server. +func (dev *RemoteDevice) Close() (err error) { + if dev.IPDevice != nil { + err = dev.IPDevice.Close() + } + return +} + +// RefreshConnectivity refreshes the connectivity to the Outline server. +func (d *RemoteDevice) RefreshConnectivity(ctx context.Context) (err error) { + if ctx.Err() != nil { + return errCancelled(ctx.Err()) + } + + slog.Debug("remote device is testing connectivity of server ...") + tcpErr, udpErr := connectivity.CheckTCPAndUDPConnectivity(d.sd, d.pl) + if tcpErr != nil { + slog.Warn("remote device server connectivity test failed", "err", tcpErr) + return tcpErr + } + + var proxy network.PacketProxy + if udpErr != nil { + slog.Warn("remote device server cannot handle UDP traffic", "err", udpErr) + proxy = d.fallback + } else { + slog.Debug("remote device server can handle UDP traffic") + proxy = d.remote + } + + if d.pkt == nil { + if d.pkt, err = network.NewDelegatePacketProxy(proxy); err != nil { + return errSetupHandler("failed to create combined datagram handler", err) + } + } else { + if err = d.pkt.SetProxy(proxy); err != nil { + return errSetupHandler("failed to update combined datagram handler", err) + } + } + + slog.Info("remote device server connectivity test done", "supportsUDP", proxy == d.remote) + return nil +} + +func errSetupHandler(msg string, cause error) error { + slog.Error(msg, "err", cause) + return perrs.PlatformError{ + Code: perrs.SetupTrafficHandlerFailed, + Message: msg, + Cause: perrs.ToPlatformError(cause), + } +} diff --git a/client/go/outline/vpn/errors.go b/client/go/outline/vpn/errors.go index 0e4b77ccde..0b2399832b 100644 --- a/client/go/outline/vpn/errors.go +++ b/client/go/outline/vpn/errors.go @@ -20,29 +20,29 @@ import ( perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -const ( - ioLogPfx = "[IO] " - nmLogPfx = "[NMDBus] " - vpnLogPfx = "[VPN] " - proxyLogPfx = "[Proxy] " -) +func errCancelled(cause error) error { + slog.Warn("operation was cancelled", "cause", cause) + return perrs.PlatformError{ + Code: perrs.OperationCanceled, + Cause: perrs.ToPlatformError(cause), + } +} func errIllegalConfig(msg string, params ...any) error { return errPlatError(perrs.IllegalConfig, msg, nil, params...) } -func errSetupVPN(pfx, msg string, cause error, params ...any) error { - return errPlatError(perrs.SetupSystemVPNFailed, pfx+msg, cause, params...) +func errSetupVPN(msg string, cause error, params ...any) error { + return errPlatError(perrs.SetupSystemVPNFailed, msg, cause, params...) } -func errCloseVPN(pfx, msg string, cause error, params ...any) error { - return errPlatError(perrs.DisconnectSystemVPNFailed, pfx+msg, cause, params...) +func errCloseVPN(msg string, cause error, params ...any) error { + return errPlatError(perrs.DisconnectSystemVPNFailed, msg, cause, params...) } func errPlatError(code perrs.ErrorCode, msg string, cause error, params ...any) error { logParams := append(params, "err", cause) slog.Error(msg, logParams...) - // time.Sleep(60 * time.Second) details := perrs.ErrorDetails{} for i := 1; i < len(params); i += 2 { diff --git a/client/go/outline/vpn/nmconn_linux.go b/client/go/outline/vpn/nmconn_linux.go index 8c3e153781..8a97e55bc9 100644 --- a/client/go/outline/vpn/nmconn_linux.go +++ b/client/go/outline/vpn/nmconn_linux.go @@ -16,7 +16,6 @@ package vpn import ( "encoding/binary" - "errors" "log/slog" "net" "time" @@ -37,7 +36,7 @@ type nmConnectionOptions struct { func establishNMConnection(nm gonm.NetworkManager, opts *nmConnectionOptions) (ac gonm.ActiveConnection, err error) { if nm == nil { - return nil, errors.New("must provide a NetworkManager") + panic("a NetworkManager must be provided") } defer func() { if err != nil { @@ -48,58 +47,58 @@ func establishNMConnection(nm gonm.NetworkManager, opts *nmConnectionOptions) (a dev, err := waitForTUNDeviceToBeAvailable(nm, opts.TUNName) if err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", opts.TUNName) + return nil, errSetupVPN("failed to find tun device", err, "tun", opts.TUNName, "api", "NetworkManager") } - slog.Debug(nmLogPfx+"located TUN device", "tun", opts.TUNName, "dev", dev.GetPath()) + slog.Debug("located tun device in NetworkManager", "tun", opts.TUNName, "dev", dev.GetPath()) if err = dev.SetPropertyManaged(true); err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to set TUN device to be managed", err, "dev", dev.GetPath()) + return nil, errSetupVPN("failed to manage tun device", err, "dev", dev.GetPath(), "api", "NetworkManager") } - slog.Debug(nmLogPfx+"set TUN device to be managed", "dev", dev.GetPath()) + slog.Debug("NetworkManager now manages the tun device", "dev", dev.GetPath()) props := make(map[string]map[string]interface{}) configureCommonProps(props, opts) configureTUNProps(props) configureIPv4Props(props, opts) - slog.Debug(nmLogPfx+"populated NetworkManager connection settings", "settings", props) + slog.Debug("populated NetworkManager connection settings", "settings", props) // The previous SetPropertyManaged call needs some time to take effect (typically within 50ms) for retries := 20; retries > 0; retries-- { - slog.Debug(nmLogPfx+"trying to create connection for TUN device ...", "dev", dev.GetPath()) + slog.Debug("trying to create NetworkManager connection for tun device...", "dev", dev.GetPath()) ac, err = nm.AddAndActivateConnection(props, dev) if err == nil { break } - slog.Debug(nmLogPfx+"waiting for TUN device being managed", "err", err) + slog.Debug("failed to create NetworkManager connection, will retry later", "err", err) time.Sleep(50 * time.Millisecond) } if err != nil { - return ac, errSetupVPN(nmLogPfx, "failed to create new connection for device", err, "dev", dev.GetPath()) + return ac, errSetupVPN("failed to create connection", err, "dev", dev.GetPath(), "api", "NetworkManager") } return } func closeNMConnection(nm gonm.NetworkManager, ac gonm.ActiveConnection) error { if nm == nil { - return errors.New("must provide a NetworkManager") + panic("a NetworkManager must be provided") } if ac == nil { return nil } if err := nm.DeactivateConnection(ac); err != nil { - slog.Warn(nmLogPfx+"not able to deactivate connection", "err", err, "conn", ac.GetPath()) + slog.Warn("failed to deactivate NetworkManager connection", "err", err, "conn", ac.GetPath()) } - slog.Debug(nmLogPfx+"deactivated connection", "conn", ac.GetPath()) + slog.Debug("deactivated NetworkManager connection", "conn", ac.GetPath()) conn, err := ac.GetPropertyConnection() if err == nil { err = conn.Delete() } if err != nil { - return errCloseVPN(nmLogPfx, "failed to delete connection", err, "conn", ac.GetPath()) + return errCloseVPN("failed to delete NetworkManager connection", err, "conn", ac.GetPath()) } - slog.Info(nmLogPfx+"connection deleted", "conn", ac.GetPath()) + slog.Info("NetworkManager connection deleted", "conn", ac.GetPath()) return nil } diff --git a/client/go/outline/vpn/tun_linux.go b/client/go/outline/vpn/tun_linux.go index f19f9917f2..5cde54d86a 100644 --- a/client/go/outline/vpn/tun_linux.go +++ b/client/go/outline/vpn/tun_linux.go @@ -37,7 +37,7 @@ func newTUNDevice(name string) (io.ReadWriteCloser, error) { return nil, err } if tun.Name() != name { - return nil, fmt.Errorf("TUN device name mismatch: requested `%s`, created `%s`", name, tun.Name()) + return nil, fmt.Errorf("tun device name mismatch: requested `%s`, created `%s`", name, tun.Name()) } return tun, nil } @@ -46,13 +46,13 @@ func newTUNDevice(name string) (io.ReadWriteCloser, error) { // in the specific NetworkManager. func waitForTUNDeviceToBeAvailable(nm gonm.NetworkManager, name string) (dev gonm.Device, err error) { for retries := 20; retries > 0; retries-- { - slog.Debug(nmLogPfx+"trying to find TUN device ...", "tun", name) + slog.Debug("trying to find tun device in NetworkManager...", "tun", name) dev, err = nm.GetDeviceByIpIface(name) if dev != nil && err == nil { return } - slog.Debug(nmLogPfx+"waiting for TUN device to be available", "err", err) + slog.Debug("waiting for tun device to be available in NetworkManager", "err", err) time.Sleep(50 * time.Millisecond) } - return nil, errSetupVPN(nmLogPfx, "failed to find TUN device", err, "tun", name) + return nil, errSetupVPN("failed to find tun device in NetworkManager", err, "tun", name) } diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index ed7d9d2707..39a800d474 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -16,12 +16,11 @@ package vpn import ( "context" - "errors" "io" "log/slog" "sync" - "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/transport" ) // Config holds the configuration to establish a system-wide [VPNConnection]. @@ -36,32 +35,6 @@ type Config struct { ProtectionMark uint32 `json:"protectionMark"` } -// Status defines the possible states of a [VPNConnection]. -type Status string - -// Constants representing the different VPN connection statuses. -const ( - StatusUnknown Status = "Unknown" - StatusConnected Status = "Connected" - StatusDisconnected Status = "Disconnected" - StatusConnecting Status = "Connecting" - StatusDisconnecting Status = "Disconnecting" -) - -// ProxyDevice is an interface representing a remote proxy server device. -type ProxyDevice interface { - network.IPDevice - - // Connect establishes a connection to the proxy device. - Connect(ctx context.Context) error - - // SupportsUDP returns true if the proxy device is able to handle UDP traffic. - SupportsUDP() bool - - // RefreshConnectivity refreshes the UDP support of the proxy device. - RefreshConnectivity(ctx context.Context) error -} - // platformVPNConn is an interface representing an OS-specific VPN connection. type platformVPNConn interface { // Establish creates a TUN device and routes all system traffic to it. @@ -76,28 +49,15 @@ type platformVPNConn interface { // VPNConnection represents a system-wide VPN connection. type VPNConnection struct { - ID string `json:"id"` - Status Status `json:"status"` - SupportsUDP *bool `json:"supportsUDP"` + ID string - ctx context.Context - cancel context.CancelFunc + cancelEst context.CancelFunc wgEst, wgCopy sync.WaitGroup - proxy ProxyDevice + proxy *RemoteDevice platform platformVPNConn } -// SetStatus sets the status of the VPN connection. -func (c *VPNConnection) SetStatus(s Status) { - c.Status = s -} - -// SetSupportsUDP sets whether the VPN connection supports UDP. -func (c *VPNConnection) SetSupportsUDP(v bool) { - c.SupportsUDP = &v -} - // The global singleton VPN connection. // This package allows at most one active VPN connection at the same time. var mu sync.Mutex @@ -108,20 +68,22 @@ var conn *VPNConnection // It first closes any active [VPNConnection] using [CloseVPN], and then marks the // newly created [VPNConnection] as the currently active connection. // It returns the new [VPNConnection], or an error if the connection fails. -func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) { +func EstablishVPN( + ctx context.Context, conf *Config, sd transport.StreamDialer, pl transport.PacketListener, +) (_ *VPNConnection, err error) { if conf == nil { - return nil, errors.New("a VPN Config must be provided") + panic("a VPN config must be provided") } - if proxy == nil { - return nil, errors.New("a proxy device must be provided") + if sd == nil { + panic("a StreamDialer must be provided") } - - c := &VPNConnection{ - ID: conf.ID, - Status: StatusDisconnected, - proxy: proxy, + if pl == nil { + panic("a PacketListener must be provided") } - c.ctx, c.cancel = context.WithCancel(context.Background()) + + c := &VPNConnection{ID: conf.ID} + ctx, c.cancelEst = context.WithCancel(ctx) + if c.platform, err = newPlatformVPNConn(conf); err != nil { return } @@ -134,25 +96,15 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) return } - slog.Debug(vpnLogPfx+"establishing VPN connection ...", "id", c.ID) - - c.SetStatus(StatusConnecting) - defer func() { - if err == nil { - c.SetStatus(StatusConnected) - } else { - c.SetStatus(StatusUnknown) - } - }() + slog.Debug("establishing vpn connection ...", "id", c.ID) - if err = c.proxy.Connect(c.ctx); err != nil { - slog.Error(proxyLogPfx+"failed to connect to the proxy", "err", err) + if c.proxy, err = ConnectRemoteDevice(ctx, sd, pl); err != nil { + slog.Error("failed to connect to the remote device", "err", err) return } - slog.Info(proxyLogPfx + "connected to the proxy") - c.SetSupportsUDP(c.proxy.SupportsUDP()) + slog.Info("connected to the remote device") - if err = c.platform.Establish(c.ctx); err != nil { + if err = c.platform.Establish(ctx); err != nil { // No need to call c.platform.Close() cuz it's already tracked in the global conn return } @@ -160,18 +112,18 @@ func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) c.wgCopy.Add(2) go func() { defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "copying traffic from TUN Device -> OutlineDevice...") + slog.Debug("copying traffic from tun device -> remote device...") n, err := io.Copy(c.proxy, c.platform.TUN()) - slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) + slog.Debug("tun device -> remote device traffic done", "n", n, "err", err) }() go func() { defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "copying traffic from OutlineDevice -> TUN Device...") + slog.Debug("copying traffic from remote device -> tun device...") n, err := io.Copy(c.platform.TUN(), c.proxy) - slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) + slog.Debug("remote device -> tun device traffic done", "n", n, "err", err) }() - slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID) + slog.Info("vpn connection established", "id", c.ID) return c, nil } @@ -186,12 +138,12 @@ func CloseVPN() error { func atomicReplaceVPNConn(newConn *VPNConnection) error { mu.Lock() defer mu.Unlock() - slog.Debug(vpnLogPfx+"creating VPN Connection ...", "id", newConn.ID) + slog.Debug("replacing the global vpn connection...", "id", newConn.ID) if err := closeVPNNoLock(); err != nil { return err } conn = newConn - slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID) + slog.Info("global vpn connection replaced", "id", newConn.ID) return nil } @@ -202,21 +154,17 @@ func closeVPNNoLock() (err error) { return nil } - conn.SetStatus(StatusDisconnecting) defer func() { if err == nil { - slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID) - conn.SetStatus(StatusDisconnected) + slog.Info("vpn connection terminated", "id", conn.ID) conn = nil - } else { - conn.SetStatus(StatusUnknown) } }() - slog.Debug(vpnLogPfx+"closing existing VPN Connection ...", "id", conn.ID) + slog.Debug("terminating the global vpn connection...", "id", conn.ID) // Cancel the Establish process and wait - conn.cancel() + conn.cancelEst() conn.wgEst.Wait() // This is the only error that matters @@ -224,9 +172,9 @@ func closeVPNNoLock() (err error) { // We can ignore the following error if err2 := conn.proxy.Close(); err2 != nil { - slog.Warn(proxyLogPfx + "failed to disconnect from the proxy") + slog.Warn("failed to disconnect from the remote device") } else { - slog.Info(proxyLogPfx + "disconnected from the proxy") + slog.Info("disconnected from the remote device") } // Wait for traffic copy go routines to finish diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index 9701d2378c..1310ee4dc0 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -67,9 +67,9 @@ func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { } if c.nm, err = gonm.NewNetworkManager(); err != nil { - return nil, errSetupVPN(nmLogPfx, "failed to connect", err) + return nil, errSetupVPN("failed to connect NetworkManager DBus", err) } - slog.Debug(nmLogPfx + "connected") + slog.Debug("NetworkManager DBus connected") return c, nil } @@ -84,14 +84,14 @@ func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { } if c.tun, err = newTUNDevice(c.nmOpts.TUNName); err != nil { - return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.Name) + return errSetupVPN("failed to create tun device", err, "name", c.nmOpts.Name) } - slog.Info(vpnLogPfx+"TUN device created", "name", c.nmOpts.TUNName) + slog.Info("tun device created", "name", c.nmOpts.TUNName) if c.ac, err = establishNMConnection(c.nm, c.nmOpts); err != nil { return } - slog.Info(nmLogPfx+"successfully configured NetworkManager connection", "conn", c.ac.GetPath()) + slog.Info("successfully configured NetworkManager connection", "conn", c.ac.GetPath()) return nil } @@ -105,9 +105,9 @@ func (c *linuxVPNConn) Close() (err error) { if c.tun != nil { // this is the only error that matters if err = c.tun.Close(); err != nil { - err = errCloseVPN(vpnLogPfx, "failed to close TUN device", err, "name", c.nmOpts.TUNName) + err = errCloseVPN("failed to delete tun device", err, "name", c.nmOpts.TUNName) } else { - slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) + slog.Info("tun device deleted", "name", c.nmOpts.TUNName) } } diff --git a/client/go/outline/vpn/vpn_others.go b/client/go/outline/vpn/vpn_others.go index 30086fd2a9..64e9d48c03 100644 --- a/client/go/outline/vpn/vpn_others.go +++ b/client/go/outline/vpn/vpn_others.go @@ -16,8 +16,6 @@ package vpn -import "errors" - func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { - return nil, errors.ErrUnsupported + panic("VPN connection not supported on non-Linux OS") } From 617d060573cb7674b430ad944f0e1217c7a8ca0a Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Thu, 19 Dec 2024 16:05:29 -0500 Subject: [PATCH 25/27] move establishVPN to a platform specific file --- client/go/outline/{vpn.go => vpn_linux.go} | 0 client/go/outline/vpn_others.go | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+) rename client/go/outline/{vpn.go => vpn_linux.go} (100%) create mode 100644 client/go/outline/vpn_others.go diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn_linux.go similarity index 100% rename from client/go/outline/vpn.go rename to client/go/outline/vpn_linux.go diff --git a/client/go/outline/vpn_others.go b/client/go/outline/vpn_others.go new file mode 100644 index 0000000000..7e0c17f4a4 --- /dev/null +++ b/client/go/outline/vpn_others.go @@ -0,0 +1,22 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package outline + +import "errors" + +func establishVPN(configStr string) error { return errors.ErrUnsupported } +func closeVPN() error { return errors.ErrUnsupported } From 61b9c423a8a7814d6668e271a70a554eaae1b31c Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Thu, 19 Dec 2024 16:13:15 -0500 Subject: [PATCH 26/27] remove unused file. --- client/go/outline/dialer_others.go | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 client/go/outline/dialer_others.go diff --git a/client/go/outline/dialer_others.go b/client/go/outline/dialer_others.go deleted file mode 100644 index 2d03fd7fb8..0000000000 --- a/client/go/outline/dialer_others.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build !linux - -package outline - -import "net" - -func newFWMarkProtectedTCPDialer(fwmark uint32) net.Dialer { - panic("SO_MARK socket option is only supported on Linux") -} - -func newFWMarkProtectedUDPDialer(fwmark uint32) net.Dialer { - panic("SO_MARK socket option is only supported on Linux") -} From 3f944f2e5ff49a64e4975ae6f80a55381e177521 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 20 Dec 2024 16:50:50 -0500 Subject: [PATCH 27/27] Replace IPDevice with ReadWriteCloser --- client/go/outline/client.go | 2 +- client/go/outline/dialer.go | 29 ----------------------------- client/go/outline/vpn/device.go | 13 +++++++------ client/go/outline/vpn/vpn.go | 14 +++++++++----- 4 files changed, 17 insertions(+), 41 deletions(-) delete mode 100644 client/go/outline/dialer.go diff --git a/client/go/outline/client.go b/client/go/outline/client.go index 6c613dbb1f..cc39396bb0 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -42,7 +42,7 @@ type NewClientResult struct { // NewClient creates a new Outline client from a configuration string. func NewClient(transportConfig string) *NewClientResult { - client, err := newClientWithBaseDialers(transportConfig, newTCPDialer(), newUDPDialer()) + client, err := newClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{}) return &NewClientResult{ Client: client, Error: platerrors.ToPlatformError(err), diff --git a/client/go/outline/dialer.go b/client/go/outline/dialer.go deleted file mode 100644 index 0e4091ba55..0000000000 --- a/client/go/outline/dialer.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2024 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package outline - -import ( - "net" -) - -// newTCPDialer creates a default base TCP dialer for [Client]. -func newTCPDialer() net.Dialer { - return net.Dialer{KeepAlive: -1} -} - -// newUDPDialer creates a default base UDP dialer for [Client]. -func newUDPDialer() net.Dialer { - return net.Dialer{} -} diff --git a/client/go/outline/vpn/device.go b/client/go/outline/vpn/device.go index daef6b9afb..6bb79c64de 100644 --- a/client/go/outline/vpn/device.go +++ b/client/go/outline/vpn/device.go @@ -17,6 +17,7 @@ package vpn import ( "context" "errors" + "io" "log/slog" "github.com/Jigsaw-Code/outline-apps/client/go/outline/connectivity" @@ -27,9 +28,9 @@ import ( "github.com/Jigsaw-Code/outline-sdk/transport" ) -// RemoteDevice is an IPDevice that connects to a remote Outline server. +// RemoteDevice is an IO device that connects to a remote Outline server. type RemoteDevice struct { - network.IPDevice + io.ReadWriteCloser sd transport.StreamDialer pl transport.PacketListener @@ -68,7 +69,7 @@ func ConnectRemoteDevice( return } - dev.IPDevice, err = lwip2transport.ConfigureDevice(sd, dev.pkt) + dev.ReadWriteCloser, err = lwip2transport.ConfigureDevice(sd, dev.pkt) if err != nil { return nil, errSetupHandler("remote device failed to configure network stack", err) } @@ -79,8 +80,8 @@ func ConnectRemoteDevice( // Close closes the connection to the Outline server. func (dev *RemoteDevice) Close() (err error) { - if dev.IPDevice != nil { - err = dev.IPDevice.Close() + if dev.ReadWriteCloser != nil { + err = dev.ReadWriteCloser.Close() } return } @@ -91,7 +92,7 @@ func (d *RemoteDevice) RefreshConnectivity(ctx context.Context) (err error) { return errCancelled(ctx.Err()) } - slog.Debug("remote device is testing connectivity of server ...") + slog.Debug("remote device is testing connectivity of server...") tcpErr, udpErr := connectivity.CheckTCPAndUDPConnectivity(d.sd, d.pl) if tcpErr != nil { slog.Warn("remote device server connectivity test failed", "err", tcpErr) diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index 39a800d474..acf35e99ca 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -168,13 +168,17 @@ func closeVPNNoLock() (err error) { conn.wgEst.Wait() // This is the only error that matters - err = conn.platform.Close() + if conn.platform != nil { + err = conn.platform.Close() + } // We can ignore the following error - if err2 := conn.proxy.Close(); err2 != nil { - slog.Warn("failed to disconnect from the remote device") - } else { - slog.Info("disconnected from the remote device") + if conn.proxy != nil { + if err2 := conn.proxy.Close(); err2 != nil { + slog.Warn("failed to disconnect from the remote device") + } else { + slog.Info("disconnected from the remote device") + } } // Wait for traffic copy go routines to finish