diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts new file mode 100644 index 0000000000..7071682c24 --- /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 {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`. + * 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 9a6948f0e5..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'; @@ -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,8 +68,7 @@ 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 promise, tied to both helper process' exits, is key to the instance's // lifecycle: @@ -85,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. @@ -104,13 +104,16 @@ export class GoVpnTunnel implements VpnTunnel { }); if (checkProxyConnectivity) { - this.isUdpEnabled = await checkConnectivity(this.connectivityChecker); + this.isUdpEnabled = await checkUDPConnectivity( + this.transportConfig, + this.isDebugMode + ); } 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 +155,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 +163,10 @@ export class GoVpnTunnel implements VpnTunnel { private async updateUdpSupport() { const wasUdpEnabled = this.isUdpEnabled; try { - this.isUdpEnabled = await checkConnectivity(this.connectivityChecker); + this.isUdpEnabled = await checkUDPConnectivity( + this.transportConfig, + this.isDebugMode + ); } catch (e) { console.error(`connectivity check failed: ${e}`); return; @@ -173,7 +179,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 +240,7 @@ class GoTun2socks { private stopRequested = false; private readonly process: ChildProcessHelper; - constructor(private readonly transportConfig: TransportConfigJson) { + constructor() { this.process = new ChildProcessHelper(pathToEmbeddedTun2socksBinary()); } @@ -244,7 +250,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 +265,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'); @@ -309,42 +318,7 @@ class GoTun2socks { return this.process.stop(); } - /** - * Checks connectivity and exits with the string of stdout. - * - * @throws ProcessTerminatedExitCodeError if tun2socks failed to run successfully. - */ - checkConnectivity() { - console.debug('[tun2socks] - checking connectivity ...'); - return this.process.launch([ - '-transport', - JSON.stringify(this.transportConfig), - '-checkConnectivity', - ]); - } - enableDebugMode() { this.process.isDebugModeEnabled = true; } } - -/** - * 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. - */ -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; - } - return true; -} diff --git a/client/electron/index.ts b/client/electron/index.ts index c26b8635f0..bf93f1d71b 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -34,6 +34,7 @@ import { import {autoUpdater} from 'electron-updater'; import {lookupIp} from './connectivity'; +import {fetchResource} from './go_helpers'; import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; @@ -292,7 +293,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 +501,12 @@ function main() { mainWindow?.webContents.send('outline-ipc-push-clipboard'); }); + // Fetches a resource (usually the dynamic key config) from a remote URL. + ipcMain.handle( + 'outline-ipc-fetch-resource', + async (_, url: string): Promise => fetchResource(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 6e59926776..fbca728353 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -69,25 +69,45 @@ var args struct { checkConnectivity *bool dnsFallback *bool version *bool + + fetchUrl *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. +// 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 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.") + + // 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() @@ -97,6 +117,15 @@ func main() { os.Exit(exitCodeSuccess) } + if *args.fetchUrl != "" { + result := outline.FetchResource(*args.fetchUrl) + if result.Error != nil { + printErrorAndExit(result.Error, exitCodeFailure) + } + fmt.Println(result.Content) + os.Exit(exitCodeSuccess) + } + setLogLevel(*args.logLevel) if len(*args.transportConfig) == 0 { diff --git a/client/go/outline/fetch.go b/client/go/outline/fetch.go new file mode 100644 index 0000000000..28078b405d --- /dev/null +++ b/client/go/outline/fetch.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" +) + +// 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 FetchResourceResult struct { + Content string + Error *platerrors.PlatformError +} + +// 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 FetchResource(url string) *FetchResourceResult { + resp, err := http.Get(url) + if err != nil { + return &FetchResourceResult{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 &FetchResourceResult{Error: &platerrors.PlatformError{ + Code: platerrors.FetchConfigFailed, + Message: "non-successful HTTP status", + Details: platerrors.ErrorDetails{ + "status": resp.Status, + "body": string(body), + }, + }} + } + if err != nil { + return &FetchResourceResult{Error: &platerrors.PlatformError{ + Code: platerrors.FetchConfigFailed, + Message: "failed to read the body", + Details: platerrors.ErrorDetails{"url": url}, + Cause: platerrors.ToPlatformError(err), + }} + } + return &FetchResourceResult{Content: string(body)} +} diff --git a/client/go/outline/fetch_test.go b/client/go/outline/fetch_test.go new file mode 100644 index 0000000000..60db9048bc --- /dev/null +++ b/client/go/outline/fetch_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 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 := FetchResource(server.URL) + require.Nil(t, result.Error) + require.Equal(t, "{\"name\": \"my-test-key\"}\n", result.Content) +} + +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") + })) + 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 := FetchResource(redirSvr.URL) + require.Nil(t, result.Error) + require.Equal(t, "ss://my-url-format-test-key\n", result.Content) + } +} + +func TestFetchResource_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 := FetchResource(server.URL) + require.Error(t, result.Error) + require.Equal(t, platerrors.FetchConfigFailed, result.Error.Code) + require.Error(t, result.Error.Cause) + } +} + +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 := FetchResource(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/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..d17dfc74bd 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.electron'; 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..d035b3a4ef 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'; 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 e3b9d73854..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, @@ -88,7 +90,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.urlFetcher, + this.tunnelConfigLocation + ); this._address = getAddressFromTransportConfig(tunnelConfig.transport); } else { tunnelConfig = this.staticTunnelConfig; @@ -164,23 +169,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( + urlFetcher: ResourceFetcher, 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 urlFetcher.fetch(configLocation.toString()) + ).trim(); if (!responseBody) { throw new errors.ServerAccessKeyInvalid( 'Got empty config from dynamic key.' 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.electron.ts b/client/src/www/app/resource_fetcher.electron.ts new file mode 100644 index 0000000000..aad193ea3d --- /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 {ResourceFetcher} from './resource_fetcher'; +import {PlatformError} from '../model/platform_error'; + +/** + * 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 new file mode 100644 index 0000000000..9b85cde41d --- /dev/null +++ b/client/src/www/app/resource_fetcher.ts @@ -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. + +import {SessionConfigFetchFailed} from '../model/errors'; + +/** + * 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(); + } +}