From 25559c4ab11dded7b22bace420805b99894539e7 Mon Sep 17 00:00:00 2001 From: Daniel LaCosse <3759828+daniellacosse@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:04:05 -0500 Subject: [PATCH 1/2] fix: add scroll overflow to contact us dialog (#2268) --- server_manager/www/ui_components/outline-contact-us-dialog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server_manager/www/ui_components/outline-contact-us-dialog.ts b/server_manager/www/ui_components/outline-contact-us-dialog.ts index ee2419c62a..8c138809a4 100644 --- a/server_manager/www/ui_components/outline-contact-us-dialog.ts +++ b/server_manager/www/ui_components/outline-contact-us-dialog.ts @@ -79,6 +79,7 @@ export class OutlineContactUsDialog paper-dialog { width: 80%; + overflow-y: scroll; } main { From e96c78176422c7fa01c5d24229a16b9223acd6f2 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Mon, 11 Nov 2024 17:48:24 -0500 Subject: [PATCH 2/2] refactor(client): more config cleanup and consolidation (#2265) --- client/electron/go_helpers.ts | 2 +- client/electron/go_vpn_tunnel.ts | 6 +- client/electron/index.ts | 10 +- client/src/www/app/app.ts | 4 +- .../outline_server_repository/access_key.ts | 67 ------ .../outline_server_repository/config.spec.ts | 135 ++++++++++++ .../app/outline_server_repository/config.ts | 193 ++++++++++++++++++ .../app/outline_server_repository/index.ts | 73 +------ .../outline_server_repository.spec.ts | 12 +- .../outline_server_repository/server.spec.ts | 48 ----- .../app/outline_server_repository/server.ts | 43 +--- .../app/outline_server_repository/vpn.fake.ts | 8 +- .../app/outline_server_repository/vpn.spec.ts | 78 ------- .../www/app/outline_server_repository/vpn.ts | 54 +---- .../root_view/add_access_key_dialog/index.ts | 25 +-- 15 files changed, 367 insertions(+), 391 deletions(-) delete mode 100644 client/src/www/app/outline_server_repository/access_key.ts create mode 100644 client/src/www/app/outline_server_repository/config.spec.ts create mode 100644 client/src/www/app/outline_server_repository/config.ts delete mode 100644 client/src/www/app/outline_server_repository/server.spec.ts delete mode 100644 client/src/www/app/outline_server_repository/vpn.spec.ts diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts index 7071682c24..ec7df45eba 100644 --- a/client/electron/go_helpers.ts +++ b/client/electron/go_helpers.ts @@ -20,7 +20,7 @@ import {pathToEmbeddedTun2socksBinary} from './app_paths'; import {ChildProcessHelper} from './process'; -import {TransportConfigJson} from '../src/www/app/outline_server_repository/vpn'; +import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; /** * 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 8539a5301c..729a84e6ea 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -21,10 +21,8 @@ import {checkUDPConnectivity} from './go_helpers'; import {ChildProcessHelper, ProcessTerminatedSignalError} from './process'; import {RoutingDaemon} from './routing_service'; import {VpnTunnel} from './vpn_tunnel'; -import { - TransportConfigJson, - TunnelStatus, -} from '../src/www/app/outline_server_repository/vpn'; +import {TransportConfigJson} from '../src/www/app/outline_server_repository/config'; +import {TunnelStatus} from '../src/www/app/outline_server_repository/vpn'; const isLinux = platform() === 'linux'; const isWindows = platform() === 'win32'; diff --git a/client/electron/index.ts b/client/electron/index.ts index bf93f1d71b..90f49fb44c 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -39,11 +39,9 @@ import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; import {VpnTunnel} from './vpn_tunnel'; +import * as config from '../src/www/app/outline_server_repository/config'; import { - getHostFromTransportConfig, - setTransportConfigHost, StartRequestJson, - TunnelConfigJson, TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; import * as errors from '../src/www/model/errors'; @@ -346,14 +344,14 @@ async function tearDownAutoLaunch() { // Factory function to create a VPNTunnel instance backed by a network stack // specified at build time. async function createVpnTunnel( - tunnelConfig: TunnelConfigJson, + tunnelConfig: config.TunnelConfigJson, isAutoConnect: boolean ): Promise { // We must convert the host from a potential "hostname" to an "IP" address // because startVpn will add a routing table entry that prefixed with this // host (e.g. "/32"), therefore must be an IP address. // TODO: make sure we resolve it in the native code - const host = getHostFromTransportConfig(tunnelConfig.transport); + const host = config.getHostFromTransportConfig(tunnelConfig.transport); if (!host) { throw new errors.IllegalServerConfiguration('host is missing'); } @@ -361,7 +359,7 @@ async function createVpnTunnel( const routing = new RoutingDaemon(hostIp || '', isAutoConnect); // Make sure the transport will use the IP we will allowlist. const resolvedTransport = - setTransportConfigHost(tunnelConfig.transport, hostIp) ?? + config.setTransportConfigHost(tunnelConfig.transport, hostIp) ?? tunnelConfig.transport; const tunnel = new GoVpnTunnel(routing, resolvedTransport); routing.onNetworkChange = tunnel.networkChanged.bind(tunnel); diff --git a/client/src/www/app/app.ts b/client/src/www/app/app.ts index e9c54e0550..b32b3a9521 100644 --- a/client/src/www/app/app.ts +++ b/client/src/www/app/app.ts @@ -19,6 +19,7 @@ import {Clipboard} from './clipboard'; import {EnvironmentVariables} from './environment'; import {localizeErrorCode} from './error_localizer'; import {OutlineServerRepository} from './outline_server_repository'; +import * as config from './outline_server_repository/config'; import {Settings, SettingsKey} from './settings'; import {Updater} from './updater'; import {UrlInterceptor} from './url_interceptor'; @@ -226,6 +227,7 @@ export class App { this.eventQueue.startPublishing(); + this.rootEl.$.addServerView.validateAccessKey = config.validateAccessKey; if (!this.arePrivacyTermsAcked()) { this.displayPrivacyView(); } else if (this.rootEl.$.serversView.shouldShowZeroState) { @@ -469,7 +471,7 @@ export class App { } } try { - this.serverRepo.validateAccessKey(accessKey); + config.validateAccessKey(accessKey); addServerView.accessKey = accessKey; addServerView.open = true; } catch (e) { diff --git a/client/src/www/app/outline_server_repository/access_key.ts b/client/src/www/app/outline_server_repository/access_key.ts deleted file mode 100644 index f650651e0e..0000000000 --- a/client/src/www/app/outline_server_repository/access_key.ts +++ /dev/null @@ -1,67 +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. - -import {SHADOWSOCKS_URI} from 'ShadowsocksConfig'; - -import {TunnelConfigJson} from './vpn'; -import * as errors from '../../model/errors'; - -/** Parses an access key string into a TunnelConfig object. */ -export function staticKeyToTunnelConfig(staticKey: string): TunnelConfigJson { - try { - const config = SHADOWSOCKS_URI.parse(staticKey); - return { - transport: { - host: config.host.data, - port: config.port.data, - method: config.method.data, - password: config.password.data, - prefix: config.extra?.['prefix'], - }, - }; - } catch (cause) { - throw new errors.ServerAccessKeyInvalid('Invalid static access key.', { - cause, - }); - } -} - -export function validateStaticKey(staticKey: string) { - let config = null; - try { - config = SHADOWSOCKS_URI.parse(staticKey); - } catch (error) { - throw new errors.ServerUrlInvalid( - error.message || 'failed to parse access key' - ); - } - if (!isShadowsocksCipherSupported(config.method.data)) { - throw new errors.ShadowsocksUnsupportedCipher( - config.method.data || 'unknown' - ); - } -} - -// We only support AEAD ciphers for Shadowsocks. -// See https://shadowsocks.org/en/spec/AEAD-Ciphers.html -const SUPPORTED_SHADOWSOCKS_CIPHERS = [ - 'chacha20-ietf-poly1305', - 'aes-128-gcm', - 'aes-192-gcm', - 'aes-256-gcm', -]; - -function isShadowsocksCipherSupported(cipher?: string): boolean { - return cipher !== undefined && SUPPORTED_SHADOWSOCKS_CIPHERS.includes(cipher); -} diff --git a/client/src/www/app/outline_server_repository/config.spec.ts b/client/src/www/app/outline_server_repository/config.spec.ts new file mode 100644 index 0000000000..a6b2d416a6 --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.spec.ts @@ -0,0 +1,135 @@ +// 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 {makeConfig, SIP002_URI} from 'ShadowsocksConfig'; + +import * as config from './config'; + +describe('getAddressFromTransport', () => { + it('extracts address', () => { + expect( + config.getAddressFromTransportConfig({host: 'example.com', port: '443'}) + ).toEqual('example.com:443'); + expect( + config.getAddressFromTransportConfig({host: '1:2::3', port: '443'}) + ).toEqual('[1:2::3]:443'); + expect(config.getAddressFromTransportConfig({host: 'example.com'})).toEqual( + 'example.com' + ); + expect(config.getAddressFromTransportConfig({host: '1:2::3'})).toEqual( + '1:2::3' + ); + }); + + it('fails on invalid config', () => { + expect(config.getAddressFromTransportConfig({})).toBeUndefined(); + }); +}); + +describe('getHostFromTransport', () => { + it('extracts host', () => { + expect( + config.getHostFromTransportConfig({host: 'example.com', port: '443'}) + ).toEqual('example.com'); + expect( + config.getHostFromTransportConfig({host: '1:2::3', port: '443'}) + ).toEqual('1:2::3'); + }); + + it('fails on invalid config', () => { + expect(config.getHostFromTransportConfig({})).toBeUndefined(); + }); +}); + +describe('setTransportHost', () => { + it('sets host', () => { + expect( + JSON.stringify( + config.setTransportConfigHost( + {host: 'example.com', port: '443'}, + '1.2.3.4' + ) + ) + ).toEqual('{"host":"1.2.3.4","port":"443"}'); + expect( + JSON.stringify( + config.setTransportConfigHost( + {host: 'example.com', port: '443'}, + '1:2::3' + ) + ) + ).toEqual('{"host":"1:2::3","port":"443"}'); + expect( + JSON.stringify( + config.setTransportConfigHost({host: '1.2.3.4', port: '443'}, '1:2::3') + ) + ).toEqual('{"host":"1:2::3","port":"443"}'); + }); + + it('fails on invalid config', () => { + expect(config.setTransportConfigHost({}, '1:2::3')).toBeUndefined(); + }); +}); + +describe('parseTunnelConfig', () => { + it('parse correctly', () => { + expect( + config.parseTunnelConfig( + '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD"}' + ) + ).toEqual({ + transport: { + host: 'example.com', + port: 443, + method: 'METHOD', + password: 'PASSWORD', + }, + }); + }); + + it('parse prefix', () => { + expect( + config.parseTunnelConfig( + '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD", "prefix": "POST "}' + ) + ).toEqual({ + transport: { + host: 'example.com', + port: 443, + method: 'METHOD', + password: 'PASSWORD', + prefix: 'POST ', + }, + }); + }); + + it('parse URL', () => { + const ssUrl = SIP002_URI.stringify( + makeConfig({ + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }) + ); + expect(config.parseTunnelConfig(ssUrl)).toEqual({ + transport: { + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }, + }); + }); +}); diff --git a/client/src/www/app/outline_server_repository/config.ts b/client/src/www/app/outline_server_repository/config.ts new file mode 100644 index 0000000000..907af931f6 --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.ts @@ -0,0 +1,193 @@ +// 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 * as net from '@outline/infrastructure/net'; +import {SHADOWSOCKS_URI} from 'ShadowsocksConfig'; + +import * as errors from '../../model/errors'; + +// Transport configuration. Application code should treat it as opaque, as it's handled by the networking layer. +export type TransportConfigJson = object; + +/** TunnelConfigJson represents the configuration to set up a tunnel. */ +export interface TunnelConfigJson { + /** transport describes how to establish connections to the destinations. + * See https://github.com/Jigsaw-Code/outline-apps/blob/master/client/go/outline/config.go for format. */ + transport: TransportConfigJson; +} + +/** + * getAddressFromTransportConfig returns the address of the tunnel server, if there's a meaningful one. + * This is used to show the server address in the UI when connected. + */ +export function getAddressFromTransportConfig( + transport: TransportConfigJson +): string | undefined { + const hostConfig: {host?: string; port?: string} = transport; + if (hostConfig.host && hostConfig.port) { + return net.joinHostPort(hostConfig.host, hostConfig.port); + } else if (hostConfig.host) { + return hostConfig.host; + } else { + return undefined; + } +} + +/** + * getHostFromTransportConfig returns the host of the tunnel server, if there's a meaningful one. + * This is used by the proxy resolution in Electron. + */ +export function getHostFromTransportConfig( + transport: TransportConfigJson +): string | undefined { + return (transport as unknown as {host: string | undefined}).host; +} + +/** + * setTransportConfigHost returns a new TransportConfigJson with the given host as the tunnel server. + * Should only be set if getHostFromTransportConfig returns one. + * This is used by the proxy resolution in Electron. + */ +export function setTransportConfigHost( + transport: TransportConfigJson, + newHost: string +): TransportConfigJson | undefined { + if (!('host' in transport)) { + return undefined; + } + return {...transport, host: newHost}; +} + +/** + * parseTunnelConfig parses the given tunnel config as text and returns a new TunnelConfigJson. + * The config text may be a "ss://" link or a JSON object. + * This is used by the server to parse the config fetched from the dynamic key. + */ +export function parseTunnelConfig( + tunnelConfigText: string +): TunnelConfigJson | null { + if (tunnelConfigText.startsWith('ss://')) { + return staticKeyToTunnelConfig(tunnelConfigText); + } + + const responseJson = JSON.parse(tunnelConfigText); + + if ('error' in responseJson) { + throw new errors.SessionProviderError( + responseJson.error.message, + responseJson.error.details + ); + } + + const transport: TransportConfigJson = { + host: responseJson.server, + port: responseJson.server_port, + method: responseJson.method, + password: responseJson.password, + }; + if (responseJson.prefix) { + (transport as {prefix?: string}).prefix = responseJson.prefix; + } + return {transport}; +} + +/** Parses an access key string into a TunnelConfig object. */ +export function staticKeyToTunnelConfig(staticKey: string): TunnelConfigJson { + try { + const config = SHADOWSOCKS_URI.parse(staticKey); + const transport: TransportConfigJson = { + host: config.host.data, + port: config.port.data, + method: config.method.data, + password: config.password.data, + }; + if (config.extra?.['prefix']) { + (transport as {prefix?: string}).prefix = config.extra?.['prefix']; + } + return {transport}; + } catch (cause) { + throw new errors.ServerAccessKeyInvalid('Invalid static access key.', { + cause, + }); + } +} + +export function validateAccessKey(accessKey: string) { + if (!isDynamicAccessKey(accessKey)) { + return validateStaticKey(accessKey); + } + + try { + // URL does not parse the hostname if the protocol is non-standard (e.g. non-http) + new URL(accessKey.replace(/^ssconf:\/\//, 'https://')); + } catch (error) { + throw new errors.ServerUrlInvalid(error.message); + } +} + +function validateStaticKey(staticKey: string) { + let config = null; + try { + config = SHADOWSOCKS_URI.parse(staticKey); + } catch (error) { + throw new errors.ServerUrlInvalid( + error.message || 'failed to parse access key' + ); + } + if (!isShadowsocksCipherSupported(config.method.data)) { + throw new errors.ShadowsocksUnsupportedCipher( + config.method.data || 'unknown' + ); + } +} + +// We only support AEAD ciphers for Shadowsocks. +// See https://shadowsocks.org/en/spec/AEAD-Ciphers.html +const SUPPORTED_SHADOWSOCKS_CIPHERS = [ + 'chacha20-ietf-poly1305', + 'aes-128-gcm', + 'aes-192-gcm', + 'aes-256-gcm', +]; + +function isShadowsocksCipherSupported(cipher?: string): boolean { + return SUPPORTED_SHADOWSOCKS_CIPHERS.includes(cipher); +} + +// TODO(daniellacosse): write unit tests for these functions +// Determines if the key is expected to be a url pointing to an ephemeral session config. +export function isDynamicAccessKey(accessKey: string): boolean { + return accessKey.startsWith('ssconf://') || accessKey.startsWith('https://'); +} + +/** + * serviceNameFromAccessKey extracts the service name from the access key. + * This is done by getting parsing the fragment hash in the URL and returning the + * entry that is not a key=value pair. + * This is used to name the service card in the UI when the service is added. + */ +export function serviceNameFromAccessKey( + accessKey: string +): string | undefined { + const {hash} = new URL(accessKey.replace(/^ss(?:conf)?:\/\//, 'https://')); + + if (!hash) return; + + return decodeURIComponent( + hash + .slice(1) + .split('&') + .find(keyValuePair => !keyValuePair.includes('=')) + ); +} diff --git a/client/src/www/app/outline_server_repository/index.ts b/client/src/www/app/outline_server_repository/index.ts index 5570ab8a3f..c4dd94cf59 100644 --- a/client/src/www/app/outline_server_repository/index.ts +++ b/client/src/www/app/outline_server_repository/index.ts @@ -16,7 +16,7 @@ import {Localizer} from '@outline/infrastructure/i18n'; import {makeConfig, SIP002_URI} from 'ShadowsocksConfig'; import uuidv4 from 'uuidv4'; -import {validateStaticKey} from './access_key'; +import * as config from './config'; import {OutlineServer} from './server'; import {TunnelStatus, VpnApi} from './vpn'; import * as errors from '../../model/errors'; @@ -24,35 +24,7 @@ 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 - -// Compares access keys proxying parameters. -function staticKeysMatch(a: string, b: string): boolean { - return a.trim() === b.trim(); -} - -// Determines if the key is expected to be a url pointing to an ephemeral session config. -function isDynamicAccessKey(accessKey: string): boolean { - return accessKey.startsWith('ssconf://') || accessKey.startsWith('https://'); -} - -// NOTE: For extracting a name that the user has explicitly set, only. -// (Currenly done by setting the hash on the URI) -function serverNameFromAccessKey(accessKey: string): string | undefined { - const {hash} = new URL(accessKey.replace(/^ss(?:conf)?:\/\//, 'https://')); - - if (!hash) return; - - return decodeURIComponent( - hash - .slice(1) - .split('&') - .find(keyValuePair => !keyValuePair.includes('=')) - ); -} - // DEPRECATED: V0 server persistence format. - interface ServersStorageV0Config { host?: string; port?: number; @@ -144,10 +116,14 @@ export class OutlineServerRepository implements ServerRepository { } add(accessKey: string) { - this.validateAccessKey(accessKey); + const alreadyAddedServer = this.serverFromAccessKey(accessKey); + if (alreadyAddedServer) { + throw new errors.ServerAlreadyAdded(alreadyAddedServer); + } + config.validateAccessKey(accessKey); // Note that serverNameFromAccessKey depends on the fact that the Access Key is a URL. - const serverName = serverNameFromAccessKey(accessKey); + const serverName = config.serviceNameFromAccessKey(accessKey); const server = this.createServer(uuidv4(), accessKey, serverName); this.serverById.set(server.id, server); @@ -199,37 +175,10 @@ export class OutlineServerRepository implements ServerRepository { this.lastForgottenServer = null; } - validateAccessKey(accessKey: string) { - if (!isDynamicAccessKey(accessKey)) { - return this.validateStaticKey(accessKey); - } - - try { - // URL does not parse the hostname if the protocol is non-standard (e.g. non-http) - new URL(accessKey.replace(/^ssconf:\/\//, 'https://')); - } catch (error) { - throw new errors.ServerUrlInvalid(error.message); - } - } - - private validateStaticKey(staticKey: string) { - const alreadyAddedServer = this.serverFromAccessKey(staticKey); - if (alreadyAddedServer) { - throw new errors.ServerAlreadyAdded(alreadyAddedServer); - } - validateStaticKey(staticKey); - } - private serverFromAccessKey(accessKey: string): OutlineServer | undefined { + const trimmedAccessKey = accessKey.trim(); for (const server of this.serverById.values()) { - if ( - server.type === ServerType.DYNAMIC_CONNECTION && - accessKey === server.accessKey - ) { - return server; - } - - if (staticKeysMatch(accessKey, server.accessKey)) { + if (trimmedAccessKey === server.accessKey.trim()) { return server; } } @@ -335,14 +284,14 @@ export class OutlineServerRepository implements ServerRepository { id, name, accessKey, - isDynamicAccessKey(accessKey) + config.isDynamicAccessKey(accessKey) ? ServerType.DYNAMIC_CONNECTION : ServerType.STATIC_CONNECTION, this.localize ); try { - this.validateAccessKey(accessKey); + config.validateAccessKey(accessKey); } catch (e) { if (e instanceof errors.ShadowsocksUnsupportedCipher) { // Don't throw for backward-compatibility. 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 4b5c167ed9..f448e5cb2f 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 @@ -21,6 +21,7 @@ import { ServersStorageV1, serversStorageV0ConfigToAccessKey, } from '.'; +import * as config from './config'; import {OutlineServer} from './server'; import {FakeVpnApi} from './vpn.fake'; import { @@ -339,15 +340,14 @@ describe('OutlineServerRepository', () => { }); it('validates static access keys', () => { - const repo = newTestRepo(new EventQueue(), new InMemoryStorage()); // Invalid access keys. - expect(() => repo.validateAccessKey('')).toThrowError(ServerUrlInvalid); - expect(() => repo.validateAccessKey('ss://invalid')).toThrowError( + expect(() => config.validateAccessKey('')).toThrowError(ServerUrlInvalid); + expect(() => config.validateAccessKey('ss://invalid')).toThrowError( ServerUrlInvalid ); // IPv6 host. expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '2001:0:ce49:7601:e866:efff:62c3:fffe', @@ -360,7 +360,7 @@ describe('OutlineServerRepository', () => { ).toBeTruthy(); // Unsupported ciphers. expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '127.0.0.1', @@ -372,7 +372,7 @@ describe('OutlineServerRepository', () => { ) ).toThrowError(ShadowsocksUnsupportedCipher); expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '127.0.0.1', diff --git a/client/src/www/app/outline_server_repository/server.spec.ts b/client/src/www/app/outline_server_repository/server.spec.ts deleted file mode 100644 index b86cd0090e..0000000000 --- a/client/src/www/app/outline_server_repository/server.spec.ts +++ /dev/null @@ -1,48 +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. - -import {TEST_ONLY} from './server'; - -describe('parseTunnelConfigJson', () => { - it('parse correctly', () => { - expect( - TEST_ONLY.parseTunnelConfigJson( - '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD"}' - ) - ).toEqual({ - transport: { - host: 'example.com', - port: 443, - method: 'METHOD', - password: 'PASSWORD', - }, - }); - }); - - it('parse prefix', () => { - expect( - TEST_ONLY.parseTunnelConfigJson( - '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD", "prefix": "POST "}' - ) - ).toEqual({ - transport: { - host: 'example.com', - port: 443, - method: 'METHOD', - password: 'PASSWORD', - prefix: 'POST ', - }, - }); - }); -}); diff --git a/client/src/www/app/outline_server_repository/server.ts b/client/src/www/app/outline_server_repository/server.ts index eee02dec83..384e3b2024 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -15,21 +15,18 @@ import {Localizer} from '@outline/infrastructure/i18n'; import * as net from '@outline/infrastructure/net'; -import {staticKeyToTunnelConfig} from './access_key'; import { - TunnelConfigJson, - TransportConfigJson, - VpnApi, - StartRequestJson, + staticKeyToTunnelConfig, + parseTunnelConfig, getAddressFromTransportConfig, -} from './vpn'; + TunnelConfigJson, +} from './config'; +import {StartRequestJson, VpnApi} from './vpn'; 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}; - // PLEASE DON'T use this class outside of this `outline_server_repository` folder! export class OutlineServer implements Server { @@ -143,30 +140,6 @@ export class OutlineServer implements Server { } } -function parseTunnelConfigJson(responseBody: string): TunnelConfigJson | null { - const responseJson = JSON.parse(responseBody); - - if ('error' in responseJson) { - throw new errors.SessionProviderError( - responseJson.error.message, - responseJson.error.details - ); - } - - const transport: TransportConfigJson = { - host: responseJson.server, - port: responseJson.server_port, - method: responseJson.method, - password: responseJson.password, - }; - if (responseJson.prefix) { - (transport as {prefix?: string}).prefix = responseJson.prefix; - } - return { - transport, - }; -} - /** fetchTunnelConfig fetches information from a dynamic access key and attempts to parse it. */ // TODO(daniellacosse): unit tests async function fetchTunnelConfig( @@ -182,11 +155,7 @@ async function fetchTunnelConfig( ); } try { - if (responseBody.startsWith('ss://')) { - return staticKeyToTunnelConfig(responseBody); - } - - return parseTunnelConfigJson(responseBody); + return parseTunnelConfig(responseBody); } catch (cause) { if (cause instanceof errors.SessionProviderError) { throw cause; 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..ebcad9942f 100644 --- a/client/src/www/app/outline_server_repository/vpn.fake.ts +++ b/client/src/www/app/outline_server_repository/vpn.fake.ts @@ -12,12 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { - VpnApi, - TunnelStatus, - StartRequestJson, - getHostFromTransportConfig, -} from './vpn'; +import {getHostFromTransportConfig} from './config'; +import {VpnApi, TunnelStatus, StartRequestJson} from './vpn'; import * as errors from '../../model/errors'; export const FAKE_BROKEN_HOSTNAME = '192.0.2.1'; diff --git a/client/src/www/app/outline_server_repository/vpn.spec.ts b/client/src/www/app/outline_server_repository/vpn.spec.ts deleted file mode 100644 index d04bc329a7..0000000000 --- a/client/src/www/app/outline_server_repository/vpn.spec.ts +++ /dev/null @@ -1,78 +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. - -import * as vpn from './vpn'; - -describe('getAddressFromTransport', () => { - it('extracts address', () => { - expect( - vpn.getAddressFromTransportConfig({host: 'example.com', port: '443'}) - ).toEqual('example.com:443'); - expect( - vpn.getAddressFromTransportConfig({host: '1:2::3', port: '443'}) - ).toEqual('[1:2::3]:443'); - expect(vpn.getAddressFromTransportConfig({host: 'example.com'})).toEqual( - 'example.com' - ); - expect(vpn.getAddressFromTransportConfig({host: '1:2::3'})).toEqual( - '1:2::3' - ); - }); - - it('fails on invalid config', () => { - expect(vpn.getAddressFromTransportConfig({})).toBeUndefined(); - }); -}); - -describe('getHostFromTransport', () => { - it('extracts host', () => { - expect( - vpn.getHostFromTransportConfig({host: 'example.com', port: '443'}) - ).toEqual('example.com'); - expect( - vpn.getHostFromTransportConfig({host: '1:2::3', port: '443'}) - ).toEqual('1:2::3'); - }); - - it('fails on invalid config', () => { - expect(vpn.getHostFromTransportConfig({})).toBeUndefined(); - }); -}); - -describe('setTransportHost', () => { - it('sets host', () => { - expect( - JSON.stringify( - vpn.setTransportConfigHost( - {host: 'example.com', port: '443'}, - '1.2.3.4' - ) - ) - ).toEqual('{"host":"1.2.3.4","port":"443"}'); - expect( - JSON.stringify( - vpn.setTransportConfigHost({host: 'example.com', port: '443'}, '1:2::3') - ) - ).toEqual('{"host":"1:2::3","port":"443"}'); - expect( - JSON.stringify( - vpn.setTransportConfigHost({host: '1.2.3.4', port: '443'}, '1:2::3') - ) - ).toEqual('{"host":"1:2::3","port":"443"}'); - }); - - it('fails on invalid config', () => { - expect(vpn.setTransportConfigHost({}, '1:2::3')).toBeUndefined(); - }); -}); diff --git a/client/src/www/app/outline_server_repository/vpn.ts b/client/src/www/app/outline_server_repository/vpn.ts index 1752bc6633..c9cccbfff2 100644 --- a/client/src/www/app/outline_server_repository/vpn.ts +++ b/client/src/www/app/outline_server_repository/vpn.ts @@ -12,49 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as net from '@outline/infrastructure/net'; - -/** - * getAddressFromTransportConfig returns the address of the tunnel server, if there's a meaningful one. - * This is used to show the server address in the UI when connected. - */ -export function getAddressFromTransportConfig( - transport: TransportConfigJson -): string | undefined { - const hostConfig: {host?: string; port?: string} = transport; - if (hostConfig.host && hostConfig.port) { - return net.joinHostPort(hostConfig.host, hostConfig.port); - } else if (hostConfig.host) { - return hostConfig.host; - } else { - return undefined; - } -} - -/** - * getHostFromTransportConfig returns the host of the tunnel server, if there's a meaningful one. - * This is used by the proxy resolution in Electron. - */ -export function getHostFromTransportConfig( - transport: TransportConfigJson -): string | undefined { - return (transport as unknown as {host: string | undefined}).host; -} - -/** - * setTransportConfigHost returns a new TransportConfigJson with the given host as the tunnel server. - * Should only be set if getHostFromTransportConfig returns one. - * This is used by the proxy resolution in Electron. - */ -export function setTransportConfigHost( - transport: TransportConfigJson, - newHost: string -): TransportConfigJson | undefined { - if (!('host' in transport)) { - return undefined; - } - return {...transport, host: newHost}; -} +import {TunnelConfigJson} from './config'; export const enum TunnelStatus { CONNECTED, @@ -63,16 +21,6 @@ export const enum TunnelStatus { DISCONNECTING, } -export type TransportConfigJson = object; - -/** TunnelConfigJson represents the configuration to set up a tunnel. */ -export interface TunnelConfigJson { - /** transport describes how to establish connections to the destinations. - * See https://github.com/Jigsaw-Code/outline-apps/blob/master/client/go/outline/config.go for format. */ - transport: TransportConfigJson; - // This is the place where routing configuration would go. -} - /** StartRequestJson is the serializable request to start the VPN, used for persistence and IPCs. */ export interface StartRequestJson { id: string; diff --git a/client/src/www/views/root_view/add_access_key_dialog/index.ts b/client/src/www/views/root_view/add_access_key_dialog/index.ts index ce37cf47e9..8d50de63b8 100644 --- a/client/src/www/views/root_view/add_access_key_dialog/index.ts +++ b/client/src/www/views/root_view/add_access_key_dialog/index.ts @@ -13,7 +13,6 @@ import {LitElement, html, css} from 'lit'; import {customElement, property} from 'lit/decorators.js'; -import {SHADOWSOCKS_URI} from 'ShadowsocksConfig'; @customElement('add-access-key-dialog') export class AddAccessKeyDialog extends LitElement { @@ -23,6 +22,7 @@ export class AddAccessKeyDialog extends LitElement { ) => string; @property({type: Boolean}) open: boolean; @property({type: String}) accessKey: string = ''; + @property({type: Function}) isValidAccessKey: (accessKey: string) => boolean; static styles = css` :host { @@ -89,7 +89,7 @@ export class AddAccessKeyDialog extends LitElement { >
${this.localize('confirm')} `; } - private get hasValidAccessKey() { - // TODO(fortuna): This needs to change to support other config URLs. - try { - SHADOWSOCKS_URI.parse(this.accessKey); - return true; - } catch { - // do nothing - } - - try { - const url = new URL(this.accessKey); - return url.protocol === 'ssconf:' || url.protocol === 'https:'; - } catch { - // do nothing - } - - return false; - } - private handleEdit(event: InputEvent) { this.accessKey = (event.target as HTMLInputElement).value; }