diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts index 80784543ad..8ee4a16e3a 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 1244e1b71a..89a9f1abb9 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 = tunnelConfig.firstHop.host; 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..ddfe2b7fdc 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,14 @@ export class App { this.eventQueue.startPublishing(); + this.rootEl.$.addServerView.isValidAccessKey = (accessKey: string) => { + try { + config.parseAccessKey(accessKey); + return true; + } catch { + return false; + } + }; if (!this.arePrivacyTermsAcked()) { this.displayPrivacyView(); } else if (this.rootEl.$.serversView.shouldShowZeroState) { @@ -469,7 +478,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..e604bbf69a --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.spec.ts @@ -0,0 +1,181 @@ +// 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.TEST_ONLY.getAddressFromTransportConfig({ + host: 'example.com', + port: 443, + }) + ).toEqual({host: 'example.com', port: 443}); + expect( + config.TEST_ONLY.getAddressFromTransportConfig({ + host: '1:2::3', + port: 443, + }) + ).toEqual({host: '1:2::3', port: 443}); + expect( + config.TEST_ONLY.getAddressFromTransportConfig({host: 'example.com'}) + ).toEqual({host: 'example.com', port: undefined}); + expect( + config.TEST_ONLY.getAddressFromTransportConfig({host: '1:2::3'}) + ).toEqual({host: '1:2::3', port: undefined}); + }); + + it('fails on invalid config', () => { + expect(config.TEST_ONLY.getAddressFromTransportConfig({})).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('parses correctly', () => { + expect( + config.parseTunnelConfig( + '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD"}' + ) + ).toEqual({ + firstHop: { + host: 'example.com', + port: 443, + }, + transport: { + host: 'example.com', + port: 443, + method: 'METHOD', + password: 'PASSWORD', + }, + }); + }); + + it('parses prefix', () => { + expect( + config.parseTunnelConfig( + '{"server": "example.com", "server_port": 443, "method": "METHOD", "password": "PASSWORD", "prefix": "POST "}' + ) + ).toEqual({ + firstHop: { + host: 'example.com', + port: 443, + }, + transport: { + host: 'example.com', + port: 443, + method: 'METHOD', + password: 'PASSWORD', + prefix: 'POST ', + }, + }); + }); + + it('parses URL', () => { + const ssUrl = SIP002_URI.stringify( + makeConfig({ + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }) + ); + expect(config.parseTunnelConfig(ssUrl)).toEqual({ + firstHop: { + host: 'example.com', + port: 443, + }, + transport: { + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }, + }); + }); + + it('parses URL with blanks', () => { + const ssUrl = SIP002_URI.stringify( + makeConfig({ + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }) + ); + expect(config.parseTunnelConfig(` ${ssUrl} \n\n\n`)).toEqual({ + firstHop: { + host: 'example.com', + port: 443, + }, + transport: { + host: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'PASSWORD', + }, + }); + }); +}); + +describe('serviceNameFromAccessKey', () => { + it('extracts name from ss:// key', () => { + expect( + config.TEST_ONLY.serviceNameFromAccessKey('ss://anything#My%20Server') + ).toEqual('My Server'); + }); + it('extracts name from ssconf:// key', () => { + expect( + config.TEST_ONLY.serviceNameFromAccessKey('ssconf://anything#My%20Server') + ).toEqual('My Server'); + }); + it('ignores parameters', () => { + expect( + config.TEST_ONLY.serviceNameFromAccessKey( + 'ss://anything#foo=bar&My%20Server&baz=boo' + ) + ).toEqual('My Server'); + }); +}); 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..3f154b577d --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.ts @@ -0,0 +1,237 @@ +// 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 * as errors from '../../model/errors'; + +export const TEST_ONLY = { + getAddressFromTransportConfig: getAddressFromTransportConfig, + serviceNameFromAccessKey: serviceNameFromAccessKey, +}; + +/** + * ServiceConfig represents an Outline service. It's the structured representation of an Access Key. + * It has a name, a tunnel config that can be statically or dynamically defined. + */ +export type ServiceConfig = StaticServiceConfig | DynamicServiceConfig; + +/** + * StaticServiceConfig is a ServiceConfig with a static tunnel config. + * It's the structured representation of a Static Access Key. + */ +export class StaticServiceConfig { + constructor( + readonly name: string, + readonly tunnelConfig: TunnelConfigJson + ) {} +} + +/** + * DynamicServiceConfig is a ServiceConfig that has the location to fetch the tunnel config. + * It's the structured representation of a Dynamic Access Key. + */ +export class DynamicServiceConfig { + constructor( + readonly name: string, + readonly transportConfigLocation: URL + ) {} +} + +/** EndpointAddress represents the address of a TCP/UDP endpoint. */ +class EndpointAddress { + readonly host: string; + readonly port: number | undefined; +} + +/** + * TunnelConfigJson represents the configuration to set up a tunnel. + * This is where VPN-layer parameters would go (e.g. interface IP, routes, dns, etc.). + */ +export interface TunnelConfigJson { + firstHop: EndpointAddress | undefined; + /** 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; +} + +/** + * TransportConfigJson represents the transport to be used. + * Application code should treat it as opaque, as it's handled by the networking layer. + */ +export type TransportConfigJson = object; + +/** + * 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. + */ +function getAddressFromTransportConfig( + transport: TransportConfigJson +): EndpointAddress | undefined { + const hostConfig: {host?: string; port?: number} = transport; + if (hostConfig.host) { + return {host: hostConfig.host, port: hostConfig?.port}; + } else { + return undefined; + } +} + +/** + * 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. + */ +// TODO(fortuna): Move config parsing to Go and do the DNS resolution and IP injection for Electron there. +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, and to parse + * static keys as tunnel configs (which may be present in the dynamic config). + */ +export function parseTunnelConfig( + tunnelConfigText: string +): TunnelConfigJson | null { + tunnelConfigText = tunnelConfigText.trim(); + 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 + ); + } + + // TODO(fortuna): stop converting to the Go format. Let the Go code convert. + // We don't validate the method because that's already done in the Go code as + // part of the Dynamic Key connection flow. + 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, + firstHop: getAddressFromTransportConfig(transport), + }; +} + +/** Parses an access key string into a TunnelConfig object. */ +function staticKeyToTunnelConfig(staticKey: string): TunnelConfigJson { + const config = SHADOWSOCKS_URI.parse(staticKey); + if (!isShadowsocksCipherSupported(config.method.data)) { + throw new errors.ShadowsocksUnsupportedCipher( + config.method.data || 'unknown' + ); + } + 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, + firstHop: getAddressFromTransportConfig(transport), + }; +} + +export function parseAccessKey(accessKey: string): ServiceConfig { + try { + accessKey = accessKey.trim(); + + // The default service name is extracted from the URL fragment of the access key. + const name = serviceNameFromAccessKey(accessKey); + + // Static ss:// keys. It encodes the full service config. + if (accessKey.startsWith('ss://')) { + return new StaticServiceConfig(name, parseTunnelConfig(accessKey)); + } + + // Dynamic ssconf:// keys. It encodes the location of the service config. + if (accessKey.startsWith('ssconf://') || accessKey.startsWith('https://')) { + try { + // URL does not parse the hostname (treats as opaque string) if the protocol is non-standard (e.g. non-http). + const configLocation = new URL( + accessKey.replace(/^ssconf:\/\//, 'https://') + ); + return new DynamicServiceConfig(name, configLocation); + } catch (error) { + throw new errors.ServerUrlInvalid(error.message); + } + } + + throw new TypeError('Access Key is not a ss:// or ssconf:// URL'); + } catch (e) { + throw new errors.ServerAccessKeyInvalid('Invalid static access key.', { + cause: e, + }); + } +} + +export function validateAccessKey(accessKey: string) { + parseAccessKey(accessKey); +} + +// 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); +} + +/** + * 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. + */ +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..1658d183cf 100644 --- a/client/src/www/app/outline_server_repository/index.ts +++ b/client/src/www/app/outline_server_repository/index.ts @@ -16,43 +16,14 @@ import {Localizer} from '@outline/infrastructure/i18n'; import {makeConfig, SIP002_URI} from 'ShadowsocksConfig'; import uuidv4 from 'uuidv4'; -import {validateStaticKey} from './access_key'; import {OutlineServer} from './server'; import {TunnelStatus, VpnApi} from './vpn'; import * as errors from '../../model/errors'; import * as events from '../../model/events'; -import {ServerRepository, ServerType} from '../../model/server'; +import {ServerRepository} 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,11 +115,11 @@ export class OutlineServerRepository implements ServerRepository { } add(accessKey: string) { - this.validateAccessKey(accessKey); - - // Note that serverNameFromAccessKey depends on the fact that the Access Key is a URL. - const serverName = serverNameFromAccessKey(accessKey); - const server = this.createServer(uuidv4(), accessKey, serverName); + const alreadyAddedServer = this.serverFromAccessKey(accessKey); + if (alreadyAddedServer) { + throw new errors.ServerAlreadyAdded(alreadyAddedServer); + } + const server = this.createServer(uuidv4(), accessKey, undefined); this.serverById.set(server.id, server); this.storeServers(); @@ -199,37 +170,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; } } @@ -329,28 +273,13 @@ export class OutlineServerRepository implements ServerRepository { accessKey: string, name?: string ): OutlineServer { - const server = new OutlineServer( + return new OutlineServer( this.vpnApi, this.urlFetcher, id, name, accessKey, - isDynamicAccessKey(accessKey) - ? ServerType.DYNAMIC_CONNECTION - : ServerType.STATIC_CONNECTION, this.localize ); - - try { - this.validateAccessKey(accessKey); - } catch (e) { - if (e instanceof errors.ShadowsocksUnsupportedCipher) { - // Don't throw for backward-compatibility. - server.errorMessageId = 'unsupported-cipher'; - } else { - throw e; - } - } - return server; } } 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..38853d10f6 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,12 +21,10 @@ import { ServersStorageV1, serversStorageV0ConfigToAccessKey, } from '.'; +import * as config from './config'; import {OutlineServer} from './server'; import {FakeVpnApi} from './vpn.fake'; -import { - ServerUrlInvalid, - ShadowsocksUnsupportedCipher, -} from '../../model/errors'; +import {ServerAccessKeyInvalid} from '../../model/errors'; import { EventQueue, ServerAdded, @@ -192,8 +190,8 @@ describe('OutlineServerRepository', () => { it('add throws on invalid access keys', () => { const repo = newTestRepo(new EventQueue(), new InMemoryStorage()); - expect(() => repo.add('ss://invalid')).toThrowError(ServerUrlInvalid); - expect(() => repo.add('')).toThrowError(ServerUrlInvalid); + expect(() => repo.add('ss://invalid')).toThrowError(ServerAccessKeyInvalid); + expect(() => repo.add('')).toThrowError(ServerAccessKeyInvalid); }); it('getAll returns added servers', () => { @@ -219,9 +217,9 @@ describe('OutlineServerRepository', () => { repo.add(accessKey); const serverId = repo.getAll()[0].id; const server = repo.getById(serverId); - expect(server.id).toEqual(serverId); - expect(server.accessKey).toEqual(accessKey); - expect(server.name).toEqual(CONFIG_0_V0.name); + expect(server?.id).toEqual(serverId); + expect(server?.accessKey).toEqual(accessKey); + expect(server?.name).toEqual(CONFIG_0_V0.name); }); it('getById returns undefined for nonexistent servers', () => { @@ -307,7 +305,7 @@ describe('OutlineServerRepository', () => { repo.forget(forgottenServerId); repo.undoForget(forgottenServerId); const forgottenServer = repo.getById(forgottenServerId); - expect(forgottenServer.id).toEqual(forgottenServerId); + expect(forgottenServer?.id).toEqual(forgottenServerId); const serverIds = repo.getAll().map(s => s.id); expect(serverIds.length).toEqual(2); expect(serverIds).toContain(forgottenServerId); @@ -339,15 +337,16 @@ 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( - ServerUrlInvalid + expect(() => config.validateAccessKey('')).toThrowError( + ServerAccessKeyInvalid + ); + expect(() => config.validateAccessKey('ss://invalid')).toThrowError( + ServerAccessKeyInvalid ); // IPv6 host. expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '2001:0:ce49:7601:e866:efff:62c3:fffe', @@ -360,7 +359,7 @@ describe('OutlineServerRepository', () => { ).toBeTruthy(); // Unsupported ciphers. expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '127.0.0.1', @@ -370,9 +369,9 @@ describe('OutlineServerRepository', () => { }) ) ) - ).toThrowError(ShadowsocksUnsupportedCipher); + ).toThrowError(ServerAccessKeyInvalid); expect(() => - repo.validateAccessKey( + config.validateAccessKey( SIP002_URI.stringify( makeConfig({ host: '127.0.0.1', @@ -382,7 +381,7 @@ describe('OutlineServerRepository', () => { }) ) ) - ).toThrowError(ShadowsocksUnsupportedCipher); + ).toThrowError(ServerAccessKeyInvalid); }); }); 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..73edd1203a 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -15,28 +15,27 @@ import {Localizer} from '@outline/infrastructure/i18n'; import * as net from '@outline/infrastructure/net'; -import {staticKeyToTunnelConfig} from './access_key'; import { + parseTunnelConfig, TunnelConfigJson, - TransportConfigJson, - VpnApi, - StartRequestJson, - getAddressFromTransportConfig, -} from './vpn'; + DynamicServiceConfig, + StaticServiceConfig, + parseAccessKey, +} 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 { - errorMessageId?: string; + public readonly type: ServerType; readonly tunnelConfigLocation: URL; - private _address: string; + private displayAddress: string; private readonly staticTunnelConfig?: TunnelConfigJson; + errorMessageId?: string; constructor( private vpnApi: VpnApi, @@ -44,47 +43,46 @@ export class OutlineServer implements Server { readonly id: string, public name: string, readonly accessKey: string, - readonly type: ServerType, localize: Localizer ) { - switch (this.type) { - case ServerType.DYNAMIC_CONNECTION: - this.tunnelConfigLocation = new URL( - accessKey.replace(/^ssconf:\/\//, 'https://') - ); - this._address = ''; - - if (!name) { - this.name = - this.tunnelConfigLocation.port === '443' - ? this.tunnelConfigLocation.hostname - : net.joinHostPort( - this.tunnelConfigLocation.hostname, - this.tunnelConfigLocation.port - ); - } - break; - - case ServerType.STATIC_CONNECTION: - default: - this.staticTunnelConfig = staticKeyToTunnelConfig(accessKey); - this._address = getAddressFromTransportConfig( - this.staticTunnelConfig.transport - ); + const serviceConfig = parseAccessKey(accessKey); + this.name = name ?? serviceConfig.name; + + if (serviceConfig instanceof DynamicServiceConfig) { + this.type = ServerType.DYNAMIC_CONNECTION; + this.tunnelConfigLocation = serviceConfig.transportConfigLocation; + this.displayAddress = ''; + + if (!this.name) { + this.name = + this.tunnelConfigLocation.port === '443' + ? this.tunnelConfigLocation.hostname + : net.joinHostPort( + this.tunnelConfigLocation.hostname, + this.tunnelConfigLocation.port + ); + } + } else if (serviceConfig instanceof StaticServiceConfig) { + this.type = ServerType.STATIC_CONNECTION; + this.staticTunnelConfig = serviceConfig.tunnelConfig; + const firstHop = serviceConfig.tunnelConfig.firstHop; + this.displayAddress = net.joinHostPort( + firstHop.host, + firstHop.port.toString() + ); - if (!name) { - this.name = localize( - accessKey.includes('outline=1') - ? 'server-default-name-outline' - : 'server-default-name' - ); - } - break; + if (!this.name) { + this.name = localize( + accessKey.includes('outline=1') + ? 'server-default-name-outline' + : 'server-default-name' + ); + } } } get address() { - return this._address; + return this.displayAddress; } async connect() { @@ -94,7 +92,10 @@ export class OutlineServer implements Server { this.urlFetcher, this.tunnelConfigLocation ); - this._address = getAddressFromTransportConfig(tunnelConfig.transport); + this.displayAddress = net.joinHostPort( + tunnelConfig.firstHop.host, + tunnelConfig.firstHop.port.toString() + ); } else { tunnelConfig = this.staticTunnelConfig; } @@ -130,7 +131,7 @@ export class OutlineServer implements Server { await this.vpnApi.stop(this.id); if (this.type === ServerType.DYNAMIC_CONNECTION) { - this._address = ''; + this.displayAddress = ''; } } catch (e) { // All the plugins treat disconnection errors as ErrorCode.UNEXPECTED. @@ -143,30 +144,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 +159,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..19c019e75d 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,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { - VpnApi, - TunnelStatus, - StartRequestJson, - getHostFromTransportConfig, -} from './vpn'; +import {VpnApi, TunnelStatus, StartRequestJson} from './vpn'; import * as errors from '../../model/errors'; export const FAKE_BROKEN_HOSTNAME = '192.0.2.1'; @@ -44,7 +39,7 @@ export class FakeVpnApi implements VpnApi { return; } - const host = getHostFromTransportConfig(request.config.transport); + const host = request.config.firstHop.host; if (this.playUnreachable(host)) { throw new errors.OutlinePluginError(errors.ErrorCode.SERVER_UNREACHABLE); } else if (this.playBroken(host)) { 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; } 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 { diff --git a/server_manager/www/ui_components/outline-region-picker-step.ts b/server_manager/www/ui_components/outline-region-picker-step.ts index 49fc44fd0e..73c5281b34 100644 --- a/server_manager/www/ui_components/outline-region-picker-step.ts +++ b/server_manager/www/ui_components/outline-region-picker-step.ts @@ -256,7 +256,11 @@ export class OutlineRegionPicker extends LitElement { ${this.localize('metrics-setup-title')}

${this.localize('metrics-setup-description')}

- ${this.localize('metrics-setup-learn-more')} + + ${this.localize('metrics-setup-learn-more')} +