From 2b7185bf8fc3e1b324188a49d5d6c6fa007d61c0 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 8 Nov 2024 16:40:35 -0500 Subject: [PATCH] clean: more config cleanup and consolidation --- client/src/www/app/app.ts | 2 + .../outline_server_repository/access_key.ts | 67 ------- .../app/outline_server_repository/config.ts | 168 ++++++++++++++++++ .../app/outline_server_repository/index.ts | 58 ++---- .../app/outline_server_repository/server.ts | 45 +---- .../app/outline_server_repository/vpn.fake.ts | 8 +- .../www/app/outline_server_repository/vpn.ts | 54 +----- .../root_view/add_access_key_dialog/index.ts | 24 +-- 8 files changed, 195 insertions(+), 231 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.ts diff --git a/client/src/www/app/app.ts b/client/src/www/app/app.ts index e9c54e05507..e5645c9e917 100644 --- a/client/src/www/app/app.ts +++ b/client/src/www/app/app.ts @@ -226,6 +226,8 @@ export class App { this.eventQueue.startPublishing(); + this.rootEl.$.addServerView.validateAccessKey = + serverRepo.validateAccessKey; if (!this.arePrivacyTermsAcked()) { this.displayPrivacyView(); } else if (this.rootEl.$.serversView.shouldShowZeroState) { 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 f650651e0e6..00000000000 --- 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.ts b/client/src/www/app/outline_server_repository/config.ts new file mode 100644 index 00000000000..eb85df13966 --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.ts @@ -0,0 +1,168 @@ +// 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 'src/www/model/errors'; + +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}; +} + +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); + 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', +]; + +export function isShadowsocksCipherSupported(cipher?: string): boolean { + return cipher !== undefined && 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://'); +} + +// NOTE: For extracting a name that the user has explicitly set, only. +// (Currenly done by setting the hash on the URI) +export 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('=')) + ); +} diff --git a/client/src/www/app/outline_server_repository/index.ts b/client/src/www/app/outline_server_repository/index.ts index 5570ab8a3fe..3da25ea8602 100644 --- a/client/src/www/app/outline_server_repository/index.ts +++ b/client/src/www/app/outline_server_repository/index.ts @@ -16,7 +16,11 @@ import {Localizer} from '@outline/infrastructure/i18n'; import {makeConfig, SIP002_URI} from 'ShadowsocksConfig'; import uuidv4 from 'uuidv4'; -import {validateStaticKey} from './access_key'; +import { + isDynamicAccessKey, + serverNameFromAccessKey, + validateStaticKey, +} from './config'; import {OutlineServer} from './server'; import {TunnelStatus, VpnApi} from './vpn'; import * as errors from '../../model/errors'; @@ -24,35 +28,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,6 +120,11 @@ export class OutlineServerRepository implements ServerRepository { } add(accessKey: string) { + accessKey = accessKey.trim(); + const alreadyAddedServer = this.serverFromAccessKey(accessKey); + if (alreadyAddedServer) { + throw new errors.ServerAlreadyAdded(alreadyAddedServer); + } this.validateAccessKey(accessKey); // Note that serverNameFromAccessKey depends on the fact that the Access Key is a URL. @@ -201,7 +182,7 @@ export class OutlineServerRepository implements ServerRepository { validateAccessKey(accessKey: string) { if (!isDynamicAccessKey(accessKey)) { - return this.validateStaticKey(accessKey); + return validateStaticKey(accessKey); } try { @@ -212,24 +193,9 @@ export class OutlineServerRepository implements ServerRepository { } } - private validateStaticKey(staticKey: string) { - const alreadyAddedServer = this.serverFromAccessKey(staticKey); - if (alreadyAddedServer) { - throw new errors.ServerAlreadyAdded(alreadyAddedServer); - } - validateStaticKey(staticKey); - } - private serverFromAccessKey(accessKey: string): OutlineServer | undefined { for (const server of this.serverById.values()) { - if ( - server.type === ServerType.DYNAMIC_CONNECTION && - accessKey === server.accessKey - ) { - return server; - } - - if (staticKeysMatch(accessKey, server.accessKey)) { + if (accessKey === server.accessKey) { return server; } } diff --git a/client/src/www/app/outline_server_repository/server.ts b/client/src/www/app/outline_server_repository/server.ts index eee02dec831..e2a4aa3b15d 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -15,20 +15,17 @@ import {Localizer} from '@outline/infrastructure/i18n'; import * as net from '@outline/infrastructure/net'; -import {staticKeyToTunnelConfig} from './access_key'; -import { - TunnelConfigJson, - TransportConfigJson, - VpnApi, - StartRequestJson, - getAddressFromTransportConfig, -} from './vpn'; +import {staticKeyToTunnelConfig} from './config'; +import {parseTunnelConfig} from './config'; +import {getAddressFromTransportConfig} from './config'; +import {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}; +export const TEST_ONLY = {parseTunnelConfigJson: parseTunnelConfig}; // PLEASE DON'T use this class outside of this `outline_server_repository` folder! @@ -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 d84041c409b..ebcad9942ff 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.ts b/client/src/www/app/outline_server_repository/vpn.ts index 1752bc6633e..c9cccbfff27 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 ce37cf47e99..7fba0b1950c 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 @@ -23,6 +23,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 +90,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; }