From 9b314caf9652494f57782c90e207239d726bbf2d Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Thu, 26 Sep 2024 18:16:34 -0400 Subject: [PATCH 01/10] refactor(client/windows): move fetching logic from TypeScript to Go --- client/go/outline/dynamic_key.go | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 client/go/outline/dynamic_key.go diff --git a/client/go/outline/dynamic_key.go b/client/go/outline/dynamic_key.go new file mode 100644 index 0000000000..833620b81c --- /dev/null +++ b/client/go/outline/dynamic_key.go @@ -0,0 +1,67 @@ +// 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 ( + "io" + "net/http" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +// FetchDynamicKeyResult represents the result of fetching a dynamic key. +// +// We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. +type FetchDynamicKeyResult struct { + Key string + Error *platerrors.PlatformError +} + +// FetchDynamicKey fetches a dynamic key from the given URL. +// +// The function makes an HTTP GET request to the specified URL and returns the response body as a +// string. If the request fails or the server returns a non-2xx status code, an error is returned. +func FetchDynamicKey(url string) *FetchDynamicKeyResult { + resp, err := http.Get(url) + if err != nil { + return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + Code: platerrors.FetchConfigFailed, + Message: "failed to fetch the URL", + Details: platerrors.ErrorDetails{"url": url}, + Cause: platerrors.ToPlatformError(err), + }} + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode > 299 { + return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + Code: platerrors.FetchConfigFailed, + Message: "non-successful HTTP status", + Details: platerrors.ErrorDetails{ + "status": resp.Status, + "body": string(body), + }, + }} + } + if err != nil { + return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + Code: platerrors.FetchConfigFailed, + Message: "failed to read the body", + Details: platerrors.ErrorDetails{"url": url}, + Cause: platerrors.ToPlatformError(err), + }} + } + return &FetchDynamicKeyResult{Key: string(body)} +} From d049b1e06eada7f4194c7c9781ef0ad8eb85a03c Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Thu, 26 Sep 2024 18:20:36 -0400 Subject: [PATCH 02/10] add electron IPC to handle fetch config --- client/electron/go_vpn_tunnel.ts | 85 ++++++++++++------- client/electron/index.ts | 13 ++- client/go/outline/electron/main.go | 13 +++ .../app/outline_server_repository/server.ts | 25 ++---- .../outline_server_repository/vpn.cordova.ts | 17 ++++ .../outline_server_repository/vpn.electron.ts | 8 ++ .../app/outline_server_repository/vpn.fake.ts | 16 ++++ .../www/app/outline_server_repository/vpn.ts | 3 + 8 files changed, 132 insertions(+), 48 deletions(-) diff --git a/client/electron/go_vpn_tunnel.ts b/client/electron/go_vpn_tunnel.ts index 9a6948f0e5..add6daa404 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -67,8 +67,8 @@ export class GoVpnTunnel implements VpnTunnel { private readonly routing: RoutingDaemon, readonly transportConfig: TransportConfigJson ) { - this.tun2socks = new GoTun2socks(transportConfig); - this.connectivityChecker = new GoTun2socks(transportConfig); + this.tun2socks = new GoTun2socks(); + this.connectivityChecker = new GoTun2socks(); // This promise, tied to both helper process' exits, is key to the instance's // lifecycle: @@ -104,13 +104,15 @@ export class GoVpnTunnel implements VpnTunnel { }); if (checkProxyConnectivity) { - this.isUdpEnabled = await checkConnectivity(this.connectivityChecker); + this.isUdpEnabled = await this.connectivityChecker.checkConnectivity( + this.transportConfig + ); } console.log(`UDP support: ${this.isUdpEnabled}`); console.log('starting routing daemon'); await Promise.all([ - this.tun2socks.start(this.isUdpEnabled), + this.tun2socks.start(this.transportConfig, this.isUdpEnabled), this.routing.start(), ]); } @@ -152,7 +154,7 @@ export class GoVpnTunnel implements VpnTunnel { console.log('restarting tun2socks after resume'); await Promise.all([ - this.tun2socks.start(this.isUdpEnabled), + this.tun2socks.start(this.transportConfig, this.isUdpEnabled), this.updateUdpSupport(), // Check if UDP support has changed; if so, silently restart. ]); } @@ -160,7 +162,9 @@ export class GoVpnTunnel implements VpnTunnel { private async updateUdpSupport() { const wasUdpEnabled = this.isUdpEnabled; try { - this.isUdpEnabled = await checkConnectivity(this.connectivityChecker); + this.isUdpEnabled = await this.connectivityChecker.checkConnectivity( + this.transportConfig + ); } catch (e) { console.error(`connectivity check failed: ${e}`); return; @@ -173,7 +177,7 @@ export class GoVpnTunnel implements VpnTunnel { // Restart tun2socks. await this.tun2socks.stop(); - await this.tun2socks.start(this.isUdpEnabled); + await this.tun2socks.start(this.transportConfig, this.isUdpEnabled); } // Use #onceDisconnected to be notified when the tunnel terminates. @@ -234,7 +238,7 @@ class GoTun2socks { private stopRequested = false; private readonly process: ChildProcessHelper; - constructor(private readonly transportConfig: TransportConfigJson) { + constructor() { this.process = new ChildProcessHelper(pathToEmbeddedTun2socksBinary()); } @@ -244,7 +248,10 @@ class GoTun2socks { * Otherwise, an error containing a JSON-formatted message will be thrown. * @param isUdpEnabled Indicates whether the remote Outline server supports UDP. */ - async start(isUdpEnabled: boolean): Promise { + async start( + config: TransportConfigJson, + isUdpEnabled: boolean + ): Promise { // ./tun2socks.exe \ // -tunName outline-tap0 -tunDNS 1.1.1.1,9.9.9.9 \ // -tunAddr 10.0.85.2 -tunGw 10.0.85.1 -tunMask 255.255.255.0 \ @@ -256,7 +263,7 @@ class GoTun2socks { args.push('-tunGw', TUN2SOCKS_VIRTUAL_ROUTER_IP); args.push('-tunMask', TUN2SOCKS_VIRTUAL_ROUTER_NETMASK); args.push('-tunDNS', DNS_RESOLVERS.join(',')); - args.push('-transport', JSON.stringify(this.transportConfig)); + args.push('-transport', JSON.stringify(config)); args.push('-logLevel', this.process.isDebugModeEnabled ? 'debug' : 'info'); if (!isUdpEnabled) { args.push('-dnsFallback'); @@ -310,17 +317,39 @@ class GoTun2socks { } /** - * Checks connectivity and exits with the string of stdout. + * Checks connectivity of the server specified in `config`. + * Checks whether proxy server is reachable, whether the network and proxy support UDP forwarding + * and validates the proxy credentials. * + * @returns A boolean indicating whether UDP forwarding is supported. * @throws ProcessTerminatedExitCodeError if tun2socks failed to run successfully. */ - checkConnectivity() { + async checkConnectivity(config: TransportConfigJson): Promise { console.debug('[tun2socks] - checking connectivity ...'); - return this.process.launch([ + const output = await this.process.launch([ '-transport', - JSON.stringify(this.transportConfig), + JSON.stringify(config), '-checkConnectivity', ]); + // Only parse the first line, because sometimes Windows Crypto API adds warnings to stdout. + const outObj = JSON.parse(output.split('\n')[0]); + if (outObj.tcp) { + throw new Error(outObj.tcp); + } + if (outObj.udp) { + return false; + } + return true; + } + + /** + * Fetches a dynamic key from the given URL. + * @param url The URL to be fetched. + * @throws ProcessTerminatedExitCodeError if we failed to fetch the config. + */ + fetchConfig(url: string): Promise { + console.debug('[tun2socks] - fetching dynamic key ...'); + return this.process.launch(['-fetchConfig', url]); } enableDebugMode() { @@ -329,22 +358,18 @@ class GoTun2socks { } /** - * Leverages the GoTun2socks binary to check connectivity to the server specified in `config`. - * Checks whether proxy server is reachable, whether the network and proxy support UDP forwarding - * and validates the proxy credentials. - * - * @returns A boolean indicating whether UDP forwarding is supported. - * @throws Error if the server is not reachable or if the process fails to start. + * Fetches a dynamic key config using native code (Go). + * @param url The HTTP(s) location of the config. + * @returns A string representing the config. + * @throws ProcessTerminatedExitCodeError if we failed to fetch the config. */ -async function checkConnectivity(tun2socks: GoTun2socks) { - const output = await tun2socks.checkConnectivity(); - // Only parse the first line, because sometimes Windows Crypto API adds warnings to stdout. - const outObj = JSON.parse(output.split('\n')[0]); - if (outObj.tcp) { - throw new Error(outObj.tcp); - } - if (outObj.udp) { - return false; +export function fetchDynamicKeyConfig( + url: string, + debugMode: boolean = false +): Promise { + const tun2socks = new GoTun2socks(); + if (debugMode) { + tun2socks.enableDebugMode(); } - return true; + return tun2socks.fetchConfig(url); } diff --git a/client/electron/index.ts b/client/electron/index.ts index c26b8635f0..c1f2affcfb 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -34,7 +34,7 @@ import { import {autoUpdater} from 'electron-updater'; import {lookupIp} from './connectivity'; -import {GoVpnTunnel} from './go_vpn_tunnel'; +import {fetchDynamicKeyConfig, GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; import {VpnTunnel} from './vpn_tunnel'; @@ -292,7 +292,9 @@ function interceptShadowsocksLink(argv: string[]) { if (mainWindow) { // The system adds a trailing slash to the intercepted URL (before the fragment). // Remove it before sending to the UI. - url = `${protocol}${url.substring(protocol.length).replace(/\/$/g, '')}`; + url = `${protocol}${url + .substring(protocol.length) + .replace(/\/$/g, '')}`; // TODO: refactor channel name and namespace to a constant mainWindow.webContents.send('outline-ipc-add-server', url); } else { @@ -498,6 +500,13 @@ function main() { mainWindow?.webContents.send('outline-ipc-push-clipboard'); }); + // Fetches dynamic key config from a remote URL. + ipcMain.handle( + 'outline-ipc-fetch-config', + async (_, url: string): Promise => + fetchDynamicKeyConfig(url, debugMode) + ); + // Connects to a proxy server specified by a config. // // If any issues occur, an Error will be thrown, which you can try-catch around diff --git a/client/go/outline/electron/main.go b/client/go/outline/electron/main.go index e4df2cdf13..c499e919b7 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -69,7 +69,10 @@ var args struct { checkConnectivity *bool dnsFallback *bool version *bool + + fetchConfig *string } + var version string // Populated at build time through `-X main.version=...` // This app sets up a local network stack to handle requests from a tun device. @@ -88,6 +91,7 @@ func main() { args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") args.dnsFallback = flag.Bool("dnsFallback", false, "Enable DNS fallback over TCP (overrides the UDP handler).") args.checkConnectivity = flag.Bool("checkConnectivity", false, "Check the proxy TCP and UDP connectivity and exit.") + args.fetchConfig = flag.String("fetchConfig", "", "The HTTPS URL of a dynamic key to fetch") args.version = flag.Bool("version", false, "Print the version and exit.") flag.Parse() @@ -97,6 +101,15 @@ func main() { os.Exit(exitCodeSuccess) } + if *args.fetchConfig != "" { + result := outline.FetchDynamicKey(*args.fetchConfig) + if result.Error != nil { + printErrorAndExit(result.Error, exitCodeFailure) + } + fmt.Println(result.Key) + os.Exit(exitCodeSuccess) + } + setLogLevel(*args.logLevel) if len(*args.transportConfig) == 0 { diff --git a/client/src/www/app/outline_server_repository/server.ts b/client/src/www/app/outline_server_repository/server.ts index e3b9d73854..f9b2d3ac75 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -88,7 +88,10 @@ export class OutlineServer implements Server { async connect() { let tunnelConfig: TunnelConfigJson; if (this.type === ServerType.DYNAMIC_CONNECTION) { - tunnelConfig = await fetchTunnelConfig(this.tunnelConfigLocation); + tunnelConfig = await fetchTunnelConfig( + this.vpnApi, + this.tunnelConfigLocation + ); this._address = getAddressFromTransportConfig(tunnelConfig.transport); } else { tunnelConfig = this.staticTunnelConfig; @@ -164,23 +167,13 @@ function parseTunnelConfigJson(responseBody: string): TunnelConfigJson | null { /** fetchTunnelConfig fetches information from a dynamic access key and attempts to parse it. */ // TODO(daniellacosse): unit tests -export async function fetchTunnelConfig( +async function fetchTunnelConfig( + vpnApi: VpnApi, configLocation: URL ): Promise { - let response; - try { - response = await fetch(configLocation, { - cache: 'no-store', - redirect: 'follow', - }); - } catch (cause) { - throw new errors.SessionConfigFetchFailed( - 'Failed to fetch VPN information from dynamic access key.', - {cause} - ); - } - - const responseBody = (await response.text()).trim(); + const responseBody = ( + await vpnApi.fetchDynamicConfig(configLocation.toString()) + ).trim(); if (!responseBody) { throw new errors.ServerAccessKeyInvalid( 'Got empty config from dynamic key.' diff --git a/client/src/www/app/outline_server_repository/vpn.cordova.ts b/client/src/www/app/outline_server_repository/vpn.cordova.ts index 1e4bf648b5..04d1d8e475 100644 --- a/client/src/www/app/outline_server_repository/vpn.cordova.ts +++ b/client/src/www/app/outline_server_repository/vpn.cordova.ts @@ -50,4 +50,21 @@ export class CordovaVpnApi implements VpnApi { console.debug('CordovaVpnApi: registering onStatusChange callback'); cordova.exec(callback, onError, OUTLINE_PLUGIN_NAME, 'onStatusChange', []); } + + // TODO: move to Go fetch implementation later + async fetchDynamicConfig(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { + cache: 'no-store', + redirect: 'follow', + }); + } catch (cause) { + throw new errors.SessionConfigFetchFailed( + 'Failed to fetch VPN information from dynamic access key.', + {cause} + ); + } + return (await response.text()).trim(); + } } diff --git a/client/src/www/app/outline_server_repository/vpn.electron.ts b/client/src/www/app/outline_server_repository/vpn.electron.ts index 81045e48b6..679f5ccd20 100644 --- a/client/src/www/app/outline_server_repository/vpn.electron.ts +++ b/client/src/www/app/outline_server_repository/vpn.electron.ts @@ -80,4 +80,12 @@ export class ElectronVpnApi implements VpnApi { onStatusChange(listener: (id: string, status: TunnelStatus) => void): void { this.statusChangeListener = listener; } + + async fetchDynamicConfig(url: string): Promise { + try { + return await window.electron.methodChannel.invoke('fetch-config', url); + } catch (e) { + throw PlatformError.parseFrom(e); + } + } } diff --git a/client/src/www/app/outline_server_repository/vpn.fake.ts b/client/src/www/app/outline_server_repository/vpn.fake.ts index d84041c409..9164cf4241 100644 --- a/client/src/www/app/outline_server_repository/vpn.fake.ts +++ b/client/src/www/app/outline_server_repository/vpn.fake.ts @@ -70,4 +70,20 @@ export class FakeVpnApi implements VpnApi { onStatusChange(_listener: (id: string, status: TunnelStatus) => void): void { // NOOP } + + async fetchDynamicConfig(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { + cache: 'no-store', + redirect: 'follow', + }); + } catch (cause) { + throw new errors.SessionConfigFetchFailed( + 'Failed to fetch VPN information from dynamic access key.', + {cause} + ); + } + return (await response.text()).trim(); + } } diff --git a/client/src/www/app/outline_server_repository/vpn.ts b/client/src/www/app/outline_server_repository/vpn.ts index 1752bc6633..cdbb31aa3d 100644 --- a/client/src/www/app/outline_server_repository/vpn.ts +++ b/client/src/www/app/outline_server_repository/vpn.ts @@ -98,4 +98,7 @@ export interface VpnApi { /** Sets a listener, to be called when the tunnel status changes. */ onStatusChange(listener: (id: string, status: TunnelStatus) => void): void; + + /** Fetches the config from a dynamic key URL. */ + fetchDynamicConfig(url: string): Promise; } From 3186d6b8745a2bb64a782f0be92cc8a0723950d8 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 4 Oct 2024 15:32:28 -0400 Subject: [PATCH 03/10] Add comments and unit test --- client/electron/go_vpn_tunnel.ts | 2 + client/go/outline/dynamic_key_test.go | 98 +++++++++++++++++++++++++++ client/go/outline/electron/main.go | 11 ++- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 client/go/outline/dynamic_key_test.go diff --git a/client/electron/go_vpn_tunnel.ts b/client/electron/go_vpn_tunnel.ts index add6daa404..847c1ad2b1 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -67,6 +67,8 @@ export class GoVpnTunnel implements VpnTunnel { private readonly routing: RoutingDaemon, readonly transportConfig: TransportConfigJson ) { + // We need two GoTun2socks instances because we need to update the UDP connectivity + // using this.connectivityChecker while this.tun2socks is still running. this.tun2socks = new GoTun2socks(); this.connectivityChecker = new GoTun2socks(); diff --git a/client/go/outline/dynamic_key_test.go b/client/go/outline/dynamic_key_test.go new file mode 100644 index 0000000000..7791620dc9 --- /dev/null +++ b/client/go/outline/dynamic_key_test.go @@ -0,0 +1,98 @@ +// 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 ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/stretchr/testify/require" +) + +func TestFetchDynamicKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintln(w, `{"name": "my-test-key"}`) + })) + defer server.Close() + + result := FetchDynamicKey(server.URL) + require.Nil(t, result.Error) + require.Equal(t, "{\"name\": \"my-test-key\"}\n", result.Key) +} + +func TestFetchDynamicKey_Redirection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintln(w, "ss://my-url-format-test-key") + })) + defer server.Close() + + redirStatuses := []int{ + http.StatusMovedPermanently, + http.StatusFound, + http.StatusSeeOther, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect, + } + + for _, redirStatus := range redirStatuses { + redirSvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, server.URL, redirStatus) + })) + defer redirSvr.Close() + + result := FetchDynamicKey(redirSvr.URL) + require.Nil(t, result.Error) + require.Equal(t, "ss://my-url-format-test-key\n", result.Key) + } +} + +func TestFetchDynamicKey_HTTPStatusError(t *testing.T) { + errStatuses := []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + } + + for _, errStatus := range errStatuses { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(errStatus) + })) + defer server.Close() + + result := FetchDynamicKey(server.URL) + require.Error(t, result.Error) + require.Equal(t, platerrors.FetchConfigFailed, result.Error.Code) + require.Error(t, result.Error.Cause) + } +} + +func TestFetchDynamicKey_BodyReadError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "1") // This will cause io.ReadAll to fail + })) + defer server.Close() + + result := FetchDynamicKey(server.URL) + require.Error(t, result.Error) + require.Equal(t, platerrors.FetchConfigFailed, result.Error.Code) + require.Error(t, result.Error.Cause) +} diff --git a/client/go/outline/electron/main.go b/client/go/outline/electron/main.go index c499e919b7..532ece8419 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -75,12 +75,19 @@ var args struct { var version string // Populated at build time through `-X main.version=...` -// This app sets up a local network stack to handle requests from a tun device. +// By default, this app sets up a local network stack to handle requests from a tun device. // // If the app runs successfully, it exits with code 0. // If there's an error, it exits with code 1 and prints a detailed error message in JSON format to stderr. // // The app also prints logs, but these are not meant to be read by the parent process. +// +// This app has two extra modes: +// +// - Connectivity Check: If you run the app with `-checkConnectivity`, it will test the proxy's connectivity +// and exit with the result printed out to standard output. +// - Fetch Config: If you run the app with `-fetchConfig`, it will fetch the dynamic key from the specified +// URL and exit with the content printed out to standard output. func main() { args.tunAddr = flag.String("tunAddr", "10.0.85.2", "TUN interface IP address") args.tunGw = flag.String("tunGw", "10.0.85.1", "TUN interface gateway") @@ -91,7 +98,7 @@ func main() { args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") args.dnsFallback = flag.Bool("dnsFallback", false, "Enable DNS fallback over TCP (overrides the UDP handler).") args.checkConnectivity = flag.Bool("checkConnectivity", false, "Check the proxy TCP and UDP connectivity and exit.") - args.fetchConfig = flag.String("fetchConfig", "", "The HTTPS URL of a dynamic key to fetch") + args.fetchConfig = flag.String("fetchConfig", "", "Fetch the dynamic key from the given URL and exit.") args.version = flag.Bool("version", false, "Print the version and exit.") flag.Parse() From ffc66b9d0f864606c563a88e4b68b71301e37174 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 11 Oct 2024 17:48:34 -0400 Subject: [PATCH 04/10] rename Go function to FetchResource --- client/go/outline/electron/main.go | 25 +++++++++++++------ .../go/outline/{dynamic_key.go => fetch.go} | 20 +++++++-------- .../{dynamic_key_test.go => fetch_test.go} | 20 +++++++-------- 3 files changed, 37 insertions(+), 28 deletions(-) rename client/go/outline/{dynamic_key.go => fetch.go} (76%) rename client/go/outline/{dynamic_key_test.go => fetch_test.go} (83%) diff --git a/client/go/outline/electron/main.go b/client/go/outline/electron/main.go index 1851278cee..fbca728353 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -70,7 +70,7 @@ var args struct { dnsFallback *bool version *bool - fetchConfig *string + fetchUrl *string } var version string // Populated at build time through `-X main.version=...` @@ -86,19 +86,28 @@ var version string // Populated at build time through `-X main.version=...` // // - Connectivity Check: If you run the app with `-checkConnectivity`, it will test the proxy's connectivity // and exit with the result printed out to standard output. -// - Fetch Config: If you run the app with `-fetchConfig`, it will fetch the dynamic key from the specified +// - Fetch Resource: If you run the app with `-fetchUrl`, it will fetch the content from the specified // URL and exit with the content printed out to standard output. func main() { + // VPN routing configs args.tunAddr = flag.String("tunAddr", "10.0.85.2", "TUN interface IP address") args.tunGw = flag.String("tunGw", "10.0.85.1", "TUN interface gateway") args.tunMask = flag.String("tunMask", "255.255.255.0", "TUN interface network mask; prefixlen for IPv6") args.tunDNS = flag.String("tunDNS", "1.1.1.1,9.9.9.9,208.67.222.222", "Comma-separated list of DNS resolvers for the TUN interface (Windows only)") args.tunName = flag.String("tunName", "tun0", "TUN interface name") - args.transportConfig = flag.String("transport", "", "A JSON object containing the transport config, UTF8-encoded") - args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") args.dnsFallback = flag.Bool("dnsFallback", false, "Enable DNS fallback over TCP (overrides the UDP handler).") + + // Proxy transport config + args.transportConfig = flag.String("transport", "", "A JSON object containing the transport config, UTF8-encoded") + + // Check connectivity of transportConfig and exit args.checkConnectivity = flag.Bool("checkConnectivity", false, "Check the proxy TCP and UDP connectivity and exit.") - args.fetchConfig = flag.String("fetchConfig", "", "Fetch the dynamic key from the given URL and exit.") + + // Fetch content of the given URL value and exit + args.fetchUrl = flag.String("fetchUrl", "", "Fetch the content from the given URL and exit.") + + // Misc + args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") args.version = flag.Bool("version", false, "Print the version and exit.") flag.Parse() @@ -108,12 +117,12 @@ func main() { os.Exit(exitCodeSuccess) } - if *args.fetchConfig != "" { - result := outline.FetchDynamicKey(*args.fetchConfig) + if *args.fetchUrl != "" { + result := outline.FetchResource(*args.fetchUrl) if result.Error != nil { printErrorAndExit(result.Error, exitCodeFailure) } - fmt.Println(result.Key) + fmt.Println(result.Content) os.Exit(exitCodeSuccess) } diff --git a/client/go/outline/dynamic_key.go b/client/go/outline/fetch.go similarity index 76% rename from client/go/outline/dynamic_key.go rename to client/go/outline/fetch.go index 833620b81c..28078b405d 100644 --- a/client/go/outline/dynamic_key.go +++ b/client/go/outline/fetch.go @@ -21,22 +21,22 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) -// FetchDynamicKeyResult represents the result of fetching a dynamic key. +// FetchResourceResult represents the result of fetching a resource located at a URL. // // We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. -type FetchDynamicKeyResult struct { - Key string - Error *platerrors.PlatformError +type FetchResourceResult struct { + Content string + Error *platerrors.PlatformError } -// FetchDynamicKey fetches a dynamic key from the given URL. +// FetchResource fetches a resource from the given URL. // // The function makes an HTTP GET request to the specified URL and returns the response body as a // string. If the request fails or the server returns a non-2xx status code, an error is returned. -func FetchDynamicKey(url string) *FetchDynamicKeyResult { +func FetchResource(url string) *FetchResourceResult { resp, err := http.Get(url) if err != nil { - return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + return &FetchResourceResult{Error: &platerrors.PlatformError{ Code: platerrors.FetchConfigFailed, Message: "failed to fetch the URL", Details: platerrors.ErrorDetails{"url": url}, @@ -46,7 +46,7 @@ func FetchDynamicKey(url string) *FetchDynamicKeyResult { body, err := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode > 299 { - return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + return &FetchResourceResult{Error: &platerrors.PlatformError{ Code: platerrors.FetchConfigFailed, Message: "non-successful HTTP status", Details: platerrors.ErrorDetails{ @@ -56,12 +56,12 @@ func FetchDynamicKey(url string) *FetchDynamicKeyResult { }} } if err != nil { - return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{ + return &FetchResourceResult{Error: &platerrors.PlatformError{ Code: platerrors.FetchConfigFailed, Message: "failed to read the body", Details: platerrors.ErrorDetails{"url": url}, Cause: platerrors.ToPlatformError(err), }} } - return &FetchDynamicKeyResult{Key: string(body)} + return &FetchResourceResult{Content: string(body)} } diff --git a/client/go/outline/dynamic_key_test.go b/client/go/outline/fetch_test.go similarity index 83% rename from client/go/outline/dynamic_key_test.go rename to client/go/outline/fetch_test.go index 7791620dc9..60db9048bc 100644 --- a/client/go/outline/dynamic_key_test.go +++ b/client/go/outline/fetch_test.go @@ -24,18 +24,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestFetchDynamicKey(t *testing.T) { +func TestFetchResource(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, `{"name": "my-test-key"}`) })) defer server.Close() - result := FetchDynamicKey(server.URL) + result := FetchResource(server.URL) require.Nil(t, result.Error) - require.Equal(t, "{\"name\": \"my-test-key\"}\n", result.Key) + require.Equal(t, "{\"name\": \"my-test-key\"}\n", result.Content) } -func TestFetchDynamicKey_Redirection(t *testing.T) { +func TestFetchResource_Redirection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, "ss://my-url-format-test-key") })) @@ -55,13 +55,13 @@ func TestFetchDynamicKey_Redirection(t *testing.T) { })) defer redirSvr.Close() - result := FetchDynamicKey(redirSvr.URL) + result := FetchResource(redirSvr.URL) require.Nil(t, result.Error) - require.Equal(t, "ss://my-url-format-test-key\n", result.Key) + require.Equal(t, "ss://my-url-format-test-key\n", result.Content) } } -func TestFetchDynamicKey_HTTPStatusError(t *testing.T) { +func TestFetchResource_HTTPStatusError(t *testing.T) { errStatuses := []int{ http.StatusBadRequest, http.StatusUnauthorized, @@ -78,20 +78,20 @@ func TestFetchDynamicKey_HTTPStatusError(t *testing.T) { })) defer server.Close() - result := FetchDynamicKey(server.URL) + result := FetchResource(server.URL) require.Error(t, result.Error) require.Equal(t, platerrors.FetchConfigFailed, result.Error.Code) require.Error(t, result.Error.Cause) } } -func TestFetchDynamicKey_BodyReadError(t *testing.T) { +func TestFetchResource_BodyReadError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Length", "1") // This will cause io.ReadAll to fail })) defer server.Close() - result := FetchDynamicKey(server.URL) + result := FetchResource(server.URL) require.Error(t, result.Error) require.Equal(t, platerrors.FetchConfigFailed, result.Error.Code) require.Error(t, result.Error.Cause) From 0391caf8bbb751cafd464535efa026d1f17cbcb5 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 11 Oct 2024 18:11:18 -0400 Subject: [PATCH 05/10] Add resource fetcher to TS --- client/src/www/app/main.cordova.ts | 6 ++ client/src/www/app/main.electron.ts | 2 + client/src/www/app/main.ts | 7 ++- .../app/outline_server_repository/index.ts | 5 +- .../outline_server_repository.spec.ts | 4 +- .../app/outline_server_repository/server.ts | 8 ++- .../outline_server_repository/vpn.cordova.ts | 17 ----- .../outline_server_repository/vpn.electron.ts | 8 --- .../app/outline_server_repository/vpn.fake.ts | 16 ----- .../www/app/outline_server_repository/vpn.ts | 3 - client/src/www/app/platform.ts | 3 + client/src/www/app/resource_fetcher.ts | 62 +++++++++++++++++++ 12 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 client/src/www/app/resource_fetcher.ts diff --git a/client/src/www/app/main.cordova.ts b/client/src/www/app/main.cordova.ts index b0ed408f6b..ab6d07cfdf 100644 --- a/client/src/www/app/main.cordova.ts +++ b/client/src/www/app/main.cordova.ts @@ -31,6 +31,7 @@ import {VpnApi} from './outline_server_repository/vpn'; import {CordovaVpnApi} from './outline_server_repository/vpn.cordova'; import {OutlinePlatform} from './platform'; import {OUTLINE_PLUGIN_NAME, pluginExec} from './plugin.cordova'; +import {BrowserResourceFetcher, ResourceFetcher} from './resource_fetcher'; import {AbstractUpdater} from './updater'; import * as interceptors from './url_interceptor'; import {NoOpVpnInstaller, VpnInstaller} from './vpn_installer'; @@ -115,6 +116,11 @@ class CordovaPlatform implements OutlinePlatform { return new NoOpVpnInstaller(); } + getResourceFetcher(): ResourceFetcher { + // TODO: move to Go fetch implementation later + return new BrowserResourceFetcher(); + } + quitApplication() { // Only used in macOS because menu bar apps provide no alternative way of quitting. cordova.exec( diff --git a/client/src/www/app/main.electron.ts b/client/src/www/app/main.electron.ts index 3c93348ffb..8a3376cfe1 100644 --- a/client/src/www/app/main.electron.ts +++ b/client/src/www/app/main.electron.ts @@ -23,6 +23,7 @@ import {AbstractClipboard} from './clipboard'; import {getLocalizationFunction, main} from './main'; import {VpnApi} from './outline_server_repository/vpn'; import {ElectronVpnApi} from './outline_server_repository/vpn.electron'; +import {ElectronResourceFetcher} from './resource_fetcher'; import {AbstractUpdater} from './updater'; import {UrlInterceptor} from './url_interceptor'; import {VpnInstaller} from './vpn_installer'; @@ -137,5 +138,6 @@ main({ getErrorReporter: _ => new ElectronErrorReporter(), getUpdater: () => new ElectronUpdater(), getVpnServiceInstaller: () => new ElectronVpnInstaller(), + getResourceFetcher: () => new ElectronResourceFetcher(), quitApplication: () => window.electron.methodChannel.send('quit-app'), }); diff --git a/client/src/www/app/main.ts b/client/src/www/app/main.ts index f451d5c5a7..6f927dcf6b 100644 --- a/client/src/www/app/main.ts +++ b/client/src/www/app/main.ts @@ -26,6 +26,7 @@ import { FakeVpnApi, } from './outline_server_repository/vpn.fake'; import {OutlinePlatform} from './platform'; +import {BrowserResourceFetcher} from './resource_fetcher.js'; import {Settings} from './settings'; import {EventQueue} from '../model/events'; @@ -60,7 +61,8 @@ function createServerRepo(platform: OutlinePlatform, eventQueue: EventQueue) { vpnApi, eventQueue, window.localStorage, - localize + localize, + platform.getResourceFetcher() ); } @@ -69,7 +71,8 @@ function createServerRepo(platform: OutlinePlatform, eventQueue: EventQueue) { new FakeVpnApi(), eventQueue, window.localStorage, - localize + localize, + new BrowserResourceFetcher() ); if (repo.getAll().length === 0) { diff --git a/client/src/www/app/outline_server_repository/index.ts b/client/src/www/app/outline_server_repository/index.ts index bd59020e16..5570ab8a3f 100644 --- a/client/src/www/app/outline_server_repository/index.ts +++ b/client/src/www/app/outline_server_repository/index.ts @@ -22,6 +22,7 @@ import {TunnelStatus, VpnApi} from './vpn'; import * as errors from '../../model/errors'; import * as events from '../../model/events'; import {ServerRepository, ServerType} from '../../model/server'; +import {ResourceFetcher} from '../resource_fetcher'; // TODO(daniellacosse): write unit tests for these functions @@ -99,7 +100,8 @@ export class OutlineServerRepository implements ServerRepository { private vpnApi: VpnApi, private eventQueue: events.EventQueue, private storage: Storage, - private localize: Localizer + private localize: Localizer, + readonly urlFetcher: ResourceFetcher ) { console.debug('OutlineServerRepository is initializing'); this.loadServers(); @@ -329,6 +331,7 @@ export class OutlineServerRepository implements ServerRepository { ): OutlineServer { const server = new OutlineServer( this.vpnApi, + this.urlFetcher, id, name, accessKey, diff --git a/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts b/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts index f06ca428aa..4b5c167ed9 100644 --- a/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts +++ b/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts @@ -34,6 +34,7 @@ import { ServerForgotten, ServerRenamed, } from '../../model/events'; +import {BrowserResourceFetcher} from '../resource_fetcher'; // TODO(alalama): unit tests for OutlineServer. @@ -395,6 +396,7 @@ function newTestRepo( storage, _ => { return 'Outline Server'; - } + }, + new BrowserResourceFetcher() ); } diff --git a/client/src/www/app/outline_server_repository/server.ts b/client/src/www/app/outline_server_repository/server.ts index f9b2d3ac75..eee02dec83 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -26,6 +26,7 @@ import { import * as errors from '../../model/errors'; import {PlatformError} from '../../model/platform_error'; import {Server, ServerType} from '../../model/server'; +import {ResourceFetcher} from '../resource_fetcher'; export const TEST_ONLY = {parseTunnelConfigJson}; @@ -39,6 +40,7 @@ export class OutlineServer implements Server { constructor( private vpnApi: VpnApi, + readonly urlFetcher: ResourceFetcher, readonly id: string, public name: string, readonly accessKey: string, @@ -89,7 +91,7 @@ export class OutlineServer implements Server { let tunnelConfig: TunnelConfigJson; if (this.type === ServerType.DYNAMIC_CONNECTION) { tunnelConfig = await fetchTunnelConfig( - this.vpnApi, + this.urlFetcher, this.tunnelConfigLocation ); this._address = getAddressFromTransportConfig(tunnelConfig.transport); @@ -168,11 +170,11 @@ function parseTunnelConfigJson(responseBody: string): TunnelConfigJson | null { /** fetchTunnelConfig fetches information from a dynamic access key and attempts to parse it. */ // TODO(daniellacosse): unit tests async function fetchTunnelConfig( - vpnApi: VpnApi, + urlFetcher: ResourceFetcher, configLocation: URL ): Promise { const responseBody = ( - await vpnApi.fetchDynamicConfig(configLocation.toString()) + await urlFetcher.fetch(configLocation.toString()) ).trim(); if (!responseBody) { throw new errors.ServerAccessKeyInvalid( diff --git a/client/src/www/app/outline_server_repository/vpn.cordova.ts b/client/src/www/app/outline_server_repository/vpn.cordova.ts index 04d1d8e475..1e4bf648b5 100644 --- a/client/src/www/app/outline_server_repository/vpn.cordova.ts +++ b/client/src/www/app/outline_server_repository/vpn.cordova.ts @@ -50,21 +50,4 @@ export class CordovaVpnApi implements VpnApi { console.debug('CordovaVpnApi: registering onStatusChange callback'); cordova.exec(callback, onError, OUTLINE_PLUGIN_NAME, 'onStatusChange', []); } - - // TODO: move to Go fetch implementation later - async fetchDynamicConfig(url: string): Promise { - let response: Response; - try { - response = await fetch(url, { - cache: 'no-store', - redirect: 'follow', - }); - } catch (cause) { - throw new errors.SessionConfigFetchFailed( - 'Failed to fetch VPN information from dynamic access key.', - {cause} - ); - } - return (await response.text()).trim(); - } } diff --git a/client/src/www/app/outline_server_repository/vpn.electron.ts b/client/src/www/app/outline_server_repository/vpn.electron.ts index 679f5ccd20..81045e48b6 100644 --- a/client/src/www/app/outline_server_repository/vpn.electron.ts +++ b/client/src/www/app/outline_server_repository/vpn.electron.ts @@ -80,12 +80,4 @@ export class ElectronVpnApi implements VpnApi { onStatusChange(listener: (id: string, status: TunnelStatus) => void): void { this.statusChangeListener = listener; } - - async fetchDynamicConfig(url: string): Promise { - try { - return await window.electron.methodChannel.invoke('fetch-config', url); - } catch (e) { - throw PlatformError.parseFrom(e); - } - } } diff --git a/client/src/www/app/outline_server_repository/vpn.fake.ts b/client/src/www/app/outline_server_repository/vpn.fake.ts index 9164cf4241..d84041c409 100644 --- a/client/src/www/app/outline_server_repository/vpn.fake.ts +++ b/client/src/www/app/outline_server_repository/vpn.fake.ts @@ -70,20 +70,4 @@ export class FakeVpnApi implements VpnApi { onStatusChange(_listener: (id: string, status: TunnelStatus) => void): void { // NOOP } - - async fetchDynamicConfig(url: string): Promise { - let response: Response; - try { - response = await fetch(url, { - cache: 'no-store', - redirect: 'follow', - }); - } catch (cause) { - throw new errors.SessionConfigFetchFailed( - 'Failed to fetch VPN information from dynamic access key.', - {cause} - ); - } - return (await response.text()).trim(); - } } diff --git a/client/src/www/app/outline_server_repository/vpn.ts b/client/src/www/app/outline_server_repository/vpn.ts index cdbb31aa3d..1752bc6633 100644 --- a/client/src/www/app/outline_server_repository/vpn.ts +++ b/client/src/www/app/outline_server_repository/vpn.ts @@ -98,7 +98,4 @@ export interface VpnApi { /** Sets a listener, to be called when the tunnel status changes. */ onStatusChange(listener: (id: string, status: TunnelStatus) => void): void; - - /** Fetches the config from a dynamic key URL. */ - fetchDynamicConfig(url: string): Promise; } diff --git a/client/src/www/app/platform.ts b/client/src/www/app/platform.ts index c5170679a7..af01aad458 100644 --- a/client/src/www/app/platform.ts +++ b/client/src/www/app/platform.ts @@ -15,6 +15,7 @@ import {Clipboard} from './clipboard'; import {EnvironmentVariables} from './environment'; import {VpnApi} from './outline_server_repository/vpn'; +import {ResourceFetcher} from './resource_fetcher'; import {Updater} from './updater'; import {UrlInterceptor} from './url_interceptor'; import {VpnInstaller} from './vpn_installer'; @@ -35,5 +36,7 @@ export interface OutlinePlatform { getVpnServiceInstaller(): VpnInstaller; + getResourceFetcher(): ResourceFetcher; + quitApplication(): void; } diff --git a/client/src/www/app/resource_fetcher.ts b/client/src/www/app/resource_fetcher.ts new file mode 100644 index 0000000000..cb3518d16c --- /dev/null +++ b/client/src/www/app/resource_fetcher.ts @@ -0,0 +1,62 @@ +// 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 {SessionConfigFetchFailed} from '../model/errors'; +import {PlatformError} from '../model/platform_error'; + +/** + * An interface for fetching resources located at a given URL. + */ +export interface ResourceFetcher { + /** + * Fetches the content of a resource located at the given URL. + * @param url The URL of the resource to fetch. + * @returns A Promise that resolves to a string containing the content of the fetched resource. + */ + fetch(url: string): Promise; +} + +/** + * Fetches resources using the browser's built-in fetch function. + */ +export class BrowserResourceFetcher implements ResourceFetcher { + async fetch(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { + cache: 'no-store', + redirect: 'follow', + }); + } catch (cause) { + throw new SessionConfigFetchFailed( + 'Failed to fetch VPN information from dynamic access key.', + {cause} + ); + } + return (await response.text()).trim(); + } +} + +/** + * Fetches resources using Electron's IPC to communicate with the main process. + */ +export class ElectronResourceFetcher implements ResourceFetcher { + async fetch(url: string): Promise { + try { + return await window.electron.methodChannel.invoke('fetch-config', url); + } catch (e) { + throw PlatformError.parseFrom(e); + } + } +} From 772947cda6151b200c93d1f4415e7a95757329f1 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 11 Oct 2024 18:21:45 -0400 Subject: [PATCH 06/10] fix build error --- client/src/www/app/main.ts | 2 +- client/src/www/app/resource_fetcher.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/www/app/main.ts b/client/src/www/app/main.ts index 6f927dcf6b..d035b3a4ef 100644 --- a/client/src/www/app/main.ts +++ b/client/src/www/app/main.ts @@ -26,7 +26,7 @@ import { FakeVpnApi, } from './outline_server_repository/vpn.fake'; import {OutlinePlatform} from './platform'; -import {BrowserResourceFetcher} from './resource_fetcher.js'; +import {BrowserResourceFetcher} from './resource_fetcher'; import {Settings} from './settings'; import {EventQueue} from '../model/events'; diff --git a/client/src/www/app/resource_fetcher.ts b/client/src/www/app/resource_fetcher.ts index cb3518d16c..ecd8e82187 100644 --- a/client/src/www/app/resource_fetcher.ts +++ b/client/src/www/app/resource_fetcher.ts @@ -44,7 +44,7 @@ export class BrowserResourceFetcher implements ResourceFetcher { {cause} ); } - return (await response.text()).trim(); + return await response.text(); } } From d1293216b64b6834d5ae674aeddf32ad7dfafa22 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 11 Oct 2024 19:27:27 -0400 Subject: [PATCH 07/10] Refactor electron main code --- client/electron/go_helpers.ts | 78 ++++++++++++++++++++++++++++++++ client/electron/go_vpn_tunnel.ts | 71 ++++------------------------- client/electron/index.ts | 6 +-- 3 files changed, 90 insertions(+), 65 deletions(-) create mode 100644 client/electron/go_helpers.ts diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts new file mode 100644 index 0000000000..b3d2f384fd --- /dev/null +++ b/client/electron/go_helpers.ts @@ -0,0 +1,78 @@ +// 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. + +/** + * @file Contains helper functions implemented in Go. + * This file provides a communication channel between TypeScript and Go, + * allowing users to call Go functions from TypeScript. + */ + +import {TransportConfigJson} from '../src/www/app/outline_server_repository/vpn'; +import {pathToEmbeddedTun2socksBinary} from './app_paths'; +import {ChildProcessHelper} from './process'; + +/** + * Verifies the UDP connectivity of the server specified in `config`. + * Checks whether proxy server is reachable, whether the network and proxy support UDP forwarding + * and validates the proxy credentials. + * + * @param config The transport configuration in JSON. + * @param debugMode Optional. Whether to forward logs to stdout. Defaults to false. + * @returns A boolean indicating whether UDP forwarding is supported. + * @throws Error if TCP connection cannot be established. + * @throws ProcessTerminatedExitCodeError if tun2socks failed to run. + */ +export async function checkUDPConnectivity( + config: TransportConfigJson, + debugMode: boolean = false +): Promise { + const tun2socks = new ChildProcessHelper(pathToEmbeddedTun2socksBinary()); + tun2socks.isDebugModeEnabled = debugMode; + + console.debug('[tun2socks] - checking connectivity ...'); + const output = await tun2socks.launch([ + '-transport', + JSON.stringify(config), + '-checkConnectivity', + ]); + + // Only parse the first line, because sometimes Windows Crypto API adds warnings to stdout. + const outObj = JSON.parse(output.split('\n')[0]); + if (outObj.tcp) { + throw new Error(outObj.tcp); + } + if (outObj.udp) { + return false; + } + return true; +} + +/** + * Fetches a resource from the given URL. + * + * @param url The URL of the resource to fetch. + * @param debugMode Optional. Whether to forward logs to stdout. Defaults to false. + * @returns A Promise that resolves to the fetched content as a string. + * @throws ProcessTerminatedExitCodeError if tun2socks failed to run. + */ +export function fetchResource( + url: string, + debugMode: boolean = false +): Promise { + const tun2socks = new ChildProcessHelper(pathToEmbeddedTun2socksBinary()); + tun2socks.isDebugModeEnabled = debugMode; + + console.debug('[tun2socks] - fetching resource ...'); + return tun2socks.launch(['-fetchUrl', url]); +} diff --git a/client/electron/go_vpn_tunnel.ts b/client/electron/go_vpn_tunnel.ts index 847c1ad2b1..f190049365 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -24,6 +24,7 @@ import { TransportConfigJson, TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; +import {checkUDPConnectivity} from './go_helpers'; const isLinux = platform() === 'linux'; const isWindows = platform() === 'win32'; @@ -49,7 +50,7 @@ const DNS_RESOLVERS = ['1.1.1.1', '9.9.9.9']; // about the others. export class GoVpnTunnel implements VpnTunnel { private readonly tun2socks: GoTun2socks; - private readonly connectivityChecker: GoTun2socks; + private isDebugMode = false; // See #resumeListener. private disconnected = false; @@ -67,10 +68,7 @@ export class GoVpnTunnel implements VpnTunnel { private readonly routing: RoutingDaemon, readonly transportConfig: TransportConfigJson ) { - // We need two GoTun2socks instances because we need to update the UDP connectivity - // using this.connectivityChecker while this.tun2socks is still running. this.tun2socks = new GoTun2socks(); - this.connectivityChecker = new GoTun2socks(); // This promise, tied to both helper process' exits, is key to the instance's // lifecycle: @@ -87,8 +85,8 @@ export class GoVpnTunnel implements VpnTunnel { // Turns on verbose logging for the managed processes. Must be called before launching the // processes enableDebugMode() { + this.isDebugMode = true; this.tun2socks.enableDebugMode(); - this.connectivityChecker.enableDebugMode(); } // Fulfills once all three helpers have started successfully. @@ -106,8 +104,9 @@ export class GoVpnTunnel implements VpnTunnel { }); if (checkProxyConnectivity) { - this.isUdpEnabled = await this.connectivityChecker.checkConnectivity( - this.transportConfig + this.isUdpEnabled = await checkUDPConnectivity( + this.transportConfig, + this.isDebugMode ); } console.log(`UDP support: ${this.isUdpEnabled}`); @@ -164,8 +163,9 @@ export class GoVpnTunnel implements VpnTunnel { private async updateUdpSupport() { const wasUdpEnabled = this.isUdpEnabled; try { - this.isUdpEnabled = await this.connectivityChecker.checkConnectivity( - this.transportConfig + this.isUdpEnabled = await checkUDPConnectivity( + this.transportConfig, + this.isDebugMode ); } catch (e) { console.error(`connectivity check failed: ${e}`); @@ -318,60 +318,7 @@ class GoTun2socks { return this.process.stop(); } - /** - * Checks connectivity of the server specified in `config`. - * Checks whether proxy server is reachable, whether the network and proxy support UDP forwarding - * and validates the proxy credentials. - * - * @returns A boolean indicating whether UDP forwarding is supported. - * @throws ProcessTerminatedExitCodeError if tun2socks failed to run successfully. - */ - async checkConnectivity(config: TransportConfigJson): Promise { - console.debug('[tun2socks] - checking connectivity ...'); - const output = await this.process.launch([ - '-transport', - JSON.stringify(config), - '-checkConnectivity', - ]); - // Only parse the first line, because sometimes Windows Crypto API adds warnings to stdout. - const outObj = JSON.parse(output.split('\n')[0]); - if (outObj.tcp) { - throw new Error(outObj.tcp); - } - if (outObj.udp) { - return false; - } - return true; - } - - /** - * Fetches a dynamic key from the given URL. - * @param url The URL to be fetched. - * @throws ProcessTerminatedExitCodeError if we failed to fetch the config. - */ - fetchConfig(url: string): Promise { - console.debug('[tun2socks] - fetching dynamic key ...'); - return this.process.launch(['-fetchConfig', url]); - } - enableDebugMode() { this.process.isDebugModeEnabled = true; } } - -/** - * Fetches a dynamic key config using native code (Go). - * @param url The HTTP(s) location of the config. - * @returns A string representing the config. - * @throws ProcessTerminatedExitCodeError if we failed to fetch the config. - */ -export function fetchDynamicKeyConfig( - url: string, - debugMode: boolean = false -): Promise { - const tun2socks = new GoTun2socks(); - if (debugMode) { - tun2socks.enableDebugMode(); - } - return tun2socks.fetchConfig(url); -} diff --git a/client/electron/index.ts b/client/electron/index.ts index c1f2affcfb..fdd14aa605 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -34,7 +34,8 @@ import { import {autoUpdater} from 'electron-updater'; import {lookupIp} from './connectivity'; -import {fetchDynamicKeyConfig, GoVpnTunnel} from './go_vpn_tunnel'; +import {fetchResource} from './go_helpers'; +import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; import {VpnTunnel} from './vpn_tunnel'; @@ -503,8 +504,7 @@ function main() { // Fetches dynamic key config from a remote URL. ipcMain.handle( 'outline-ipc-fetch-config', - async (_, url: string): Promise => - fetchDynamicKeyConfig(url, debugMode) + async (_, url: string): Promise => fetchResource(url, debugMode) ); // Connects to a proxy server specified by a config. From 2131b2c6bd03a848457ec0c6de429abc277a1f21 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Fri, 11 Oct 2024 19:31:26 -0400 Subject: [PATCH 08/10] fix linting errors --- client/electron/go_helpers.ts | 2 +- client/electron/go_vpn_tunnel.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts index b3d2f384fd..7071682c24 100644 --- a/client/electron/go_helpers.ts +++ b/client/electron/go_helpers.ts @@ -18,9 +18,9 @@ * allowing users to call Go functions from TypeScript. */ -import {TransportConfigJson} from '../src/www/app/outline_server_repository/vpn'; import {pathToEmbeddedTun2socksBinary} from './app_paths'; import {ChildProcessHelper} from './process'; +import {TransportConfigJson} from '../src/www/app/outline_server_repository/vpn'; /** * Verifies the UDP connectivity of the server specified in `config`. diff --git a/client/electron/go_vpn_tunnel.ts b/client/electron/go_vpn_tunnel.ts index f190049365..8539a5301c 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -17,6 +17,7 @@ import {platform} from 'os'; import {powerMonitor} from 'electron'; import {pathToEmbeddedTun2socksBinary} from './app_paths'; +import {checkUDPConnectivity} from './go_helpers'; import {ChildProcessHelper, ProcessTerminatedSignalError} from './process'; import {RoutingDaemon} from './routing_service'; import {VpnTunnel} from './vpn_tunnel'; @@ -24,7 +25,6 @@ import { TransportConfigJson, TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; -import {checkUDPConnectivity} from './go_helpers'; const isLinux = platform() === 'linux'; const isWindows = platform() === 'win32'; From 160402e8c5216d6684bfa46b4a6fc4fd9fc458b0 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 14 Oct 2024 18:19:36 -0400 Subject: [PATCH 09/10] code review --- client/electron/index.ts | 4 +-- client/src/www/app/main.electron.ts | 2 +- .../src/www/app/resource_fetcher.electron.ts | 29 +++++++++++++++++++ client/src/www/app/resource_fetcher.ts | 14 --------- 4 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 client/src/www/app/resource_fetcher.electron.ts diff --git a/client/electron/index.ts b/client/electron/index.ts index fdd14aa605..bf93f1d71b 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -501,9 +501,9 @@ function main() { mainWindow?.webContents.send('outline-ipc-push-clipboard'); }); - // Fetches dynamic key config from a remote URL. + // Fetches a resource (usually the dynamic key config) from a remote URL. ipcMain.handle( - 'outline-ipc-fetch-config', + 'outline-ipc-fetch-resource', async (_, url: string): Promise => fetchResource(url, debugMode) ); diff --git a/client/src/www/app/main.electron.ts b/client/src/www/app/main.electron.ts index 8a3376cfe1..d17dfc74bd 100644 --- a/client/src/www/app/main.electron.ts +++ b/client/src/www/app/main.electron.ts @@ -23,7 +23,7 @@ import {AbstractClipboard} from './clipboard'; import {getLocalizationFunction, main} from './main'; import {VpnApi} from './outline_server_repository/vpn'; import {ElectronVpnApi} from './outline_server_repository/vpn.electron'; -import {ElectronResourceFetcher} from './resource_fetcher'; +import {ElectronResourceFetcher} from './resource_fetcher.electron'; import {AbstractUpdater} from './updater'; import {UrlInterceptor} from './url_interceptor'; import {VpnInstaller} from './vpn_installer'; diff --git a/client/src/www/app/resource_fetcher.electron.ts b/client/src/www/app/resource_fetcher.electron.ts new file mode 100644 index 0000000000..4ccd6d352d --- /dev/null +++ b/client/src/www/app/resource_fetcher.electron.ts @@ -0,0 +1,29 @@ +// 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 {PlatformError} from '../model/platform_error'; +import {ResourceFetcher} from './resource_fetcher'; + +/** + * Fetches resources using Electron's IPC to communicate with the main process. + */ +export class ElectronResourceFetcher implements ResourceFetcher { + async fetch(url: string): Promise { + try { + return await window.electron.methodChannel.invoke('fetch-resource', url); + } catch (e) { + throw PlatformError.parseFrom(e); + } + } +} diff --git a/client/src/www/app/resource_fetcher.ts b/client/src/www/app/resource_fetcher.ts index ecd8e82187..9b85cde41d 100644 --- a/client/src/www/app/resource_fetcher.ts +++ b/client/src/www/app/resource_fetcher.ts @@ -13,7 +13,6 @@ // limitations under the License. import {SessionConfigFetchFailed} from '../model/errors'; -import {PlatformError} from '../model/platform_error'; /** * An interface for fetching resources located at a given URL. @@ -47,16 +46,3 @@ export class BrowserResourceFetcher implements ResourceFetcher { return await response.text(); } } - -/** - * Fetches resources using Electron's IPC to communicate with the main process. - */ -export class ElectronResourceFetcher implements ResourceFetcher { - async fetch(url: string): Promise { - try { - return await window.electron.methodChannel.invoke('fetch-config', url); - } catch (e) { - throw PlatformError.parseFrom(e); - } - } -} From 627562de56f7dcdc6934e1484cce7491f199d164 Mon Sep 17 00:00:00 2001 From: "J. Yi" <93548144+jyyi1@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:29:55 -0400 Subject: [PATCH 10/10] fix linting error --- client/src/www/app/resource_fetcher.electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/www/app/resource_fetcher.electron.ts b/client/src/www/app/resource_fetcher.electron.ts index 4ccd6d352d..aad193ea3d 100644 --- a/client/src/www/app/resource_fetcher.electron.ts +++ b/client/src/www/app/resource_fetcher.electron.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {PlatformError} from '../model/platform_error'; import {ResourceFetcher} from './resource_fetcher'; +import {PlatformError} from '../model/platform_error'; /** * Fetches resources using Electron's IPC to communicate with the main process.