diff --git a/client/electron/index.ts b/client/electron/index.ts index c26b8635f0..eb09fed9c0 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -39,12 +39,13 @@ import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; import {VpnTunnel} from './vpn_tunnel'; import { - getHostFromTransportConfig, - setTransportConfigHost, StartRequestJson, - TunnelConfigJson, TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; +import { + setTransportConfigHost, + TunnelConfigJson, +} from '../src/www/app/outline_server_repository/config'; import * as errors from '../src/www/model/errors'; // TODO: can we define these macros in other .d.ts files with default values? diff --git a/client/go/outline/client.go b/client/go/outline/client.go index 351be9c177..e649570fb0 100644 --- a/client/go/outline/client.go +++ b/client/go/outline/client.go @@ -56,6 +56,7 @@ func NewClientAndReturnError(transportConfig string) *NewClientResult { // // Deprecated: Use [NewClientAndReturnError] instead. func NewClient(transportConfig string) (*Client, error) { + // TODO: use normalized format. config, err := parseConfigFromJSON(transportConfig) if err != nil { return nil, err diff --git a/client/src/www/app/outline_server_repository/access_key.ts b/client/src/www/app/outline_server_repository/access_key.ts index f650651e0e..017b2ab47a 100644 --- a/client/src/www/app/outline_server_repository/access_key.ts +++ b/client/src/www/app/outline_server_repository/access_key.ts @@ -14,7 +14,7 @@ import {SHADOWSOCKS_URI} from 'ShadowsocksConfig'; -import {TunnelConfigJson} from './vpn'; +import {TunnelConfigJson} from './config'; import * as errors from '../../model/errors'; /** Parses an access key string into a TunnelConfig object. */ @@ -23,10 +23,14 @@ export function staticKeyToTunnelConfig(staticKey: string): TunnelConfigJson { const config = SHADOWSOCKS_URI.parse(staticKey); return { transport: { - host: config.host.data, - port: config.port.data, - method: config.method.data, - password: config.password.data, + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: config.host.data, + port: config.port.data, + }, + cipher: config.method.data, + secret: config.password.data, prefix: config.extra?.['prefix'], }, }; 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..8cc0e41c7b --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.spec.ts @@ -0,0 +1,375 @@ +// 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 config from './config'; + +describe('newTunnelJson', () => { + it('parses dynamic key', () => { + expect( + config.newTunnelJson({ + server: 'example.com', + server_port: 443, + method: 'METHOD', + password: 'PASSWORD', + }) + ).toEqual({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', + }, + } as config.TunnelConfigJson); + }); + + it('parses prefix', () => { + expect( + config.newTunnelJson({ + server: 'example.com', + server_port: 443, + method: 'METHOD', + password: 'PASSWORD', + prefix: '\x03\x02\x03', + }) + ).toEqual({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', + prefix: '\x03\x02\x03', + }, + } as config.TunnelConfigJson); + }); + + it('fails on missing server', () => { + expect(() => { + config.newTunnelJson({ + server_port: 443, + method: 'METHOD', + password: 'PASSWORD', + }); + }).toThrow(); + }); + + it('fails on missing port', () => { + expect(() => { + config.newTunnelJson({ + server: 'example.com', + method: 'METHOD', + password: 'PASSWORD', + }); + }).toThrow(); + }); + + it('fails on missing method', () => { + expect(() => { + config.newTunnelJson({ + server: 'example.com', + server_port: 443, + password: 'PASSWORD', + }); + }).toThrow(); + }); + + it('fails on missing password', () => { + expect(() => { + config.newTunnelJson({ + server: 'example.com', + server_port: 443, + method: 'METHOD', + }); + }).toThrow(); + }); + + it('parses new format', () => { + expect( + config.newTunnelJson({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', + prefix: '\x03\x02\x03', + }, + }) + ).toEqual({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', + prefix: '\x03\x02\x03', + }, + } as config.TunnelConfigJson); + }); + + it('parses abbreviated endpoint', () => { + expect( + config.newTunnelJson({ + transport: { + type: 'shadowsocks', + endpoint: 'example.com:443', + cipher: 'METHOD', + secret: 'PASSWORD', + prefix: '\x03\x02\x03', + }, + }) + ).toEqual({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', + prefix: '\x03\x02\x03', + }, + } as config.TunnelConfigJson); + }); + + it('parses multi-hop', () => { + expect( + config.newTunnelJson({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'server2.com', + port: 443, + dialer: { + type: 'shadowsocks', + endpoint: 'server1.com:8888', + cipher: 'METHOD1', + secret: 'PASSWORD1', + prefix: '\x01\x01\x01', + }, + }, + cipher: 'METHOD2', + secret: 'PASSWORD2', + prefix: '\x03\x02\x03', + }, + }) + ).toEqual({ + transport: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'server2.com', + port: 443, + dialer: { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'server1.com', + port: 8888, + }, + cipher: 'METHOD1', + secret: 'PASSWORD1', + prefix: '\x01\x01\x01', + }, + }, + cipher: 'METHOD2', + secret: 'PASSWORD2', + prefix: '\x03\x02\x03', + }, + } as config.TunnelConfigJson); + }); + + it('parses pipe', () => { + expect( + config.newTunnelJson({ + transport: [ + { + type: 'shadowsocks', + endpoint: 'server1.com:8888', + cipher: 'METHOD1', + secret: 'PASSWORD1', + prefix: '\x01\x01\x01', + }, + { + type: 'shadowsocks', + endpoint: 'server2.com:443', + cipher: 'METHOD2', + secret: 'PASSWORD2', + prefix: '\x03\x02\x03', + }, + ], + }) + ).toEqual({ + transport: { + type: 'pipe', + dialers: [ + { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'server1.com', + port: 8888, + }, + cipher: 'METHOD1', + secret: 'PASSWORD1', + prefix: '\x01\x01\x01', + }, + { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'server2.com', + port: 443, + }, + cipher: 'METHOD2', + secret: 'PASSWORD2', + prefix: '\x03\x02\x03', + }, + ], + }, + } as config.TunnelConfigJson); + }); +}); + +describe('getAddressFromTransport', () => { + it('extracts address', () => { + expect( + config.getAddressFromTransportConfig({ + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + } as config.TransportConfigJson) + ).toEqual('example.com:443'); + expect( + config.getAddressFromTransportConfig({ + endpoint: { + type: 'dial', + host: '1:2::3', + port: 443, + }, + } as config.TransportConfigJson) + ).toEqual('[1:2::3]:443'); + }); + + it('fails on invalid config', () => { + expect( + config.getAddressFromTransportConfig( + {} as unknown as config.TransportConfigJson + ) + ).toBeUndefined(); + }); +}); + +describe('getHostFromTransport', () => { + it('extracts host', () => { + expect( + config.getHostFromTransportConfig({ + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + }, + } as config.TransportConfigJson) + ).toEqual('example.com'); + expect( + config.getHostFromTransportConfig({ + endpoint: { + type: 'dial', + host: '1:2::3', + }, + } as config.TransportConfigJson) + ).toEqual('1:2::3'); + }); + + it('fails on invalid config', () => { + expect( + config.getHostFromTransportConfig( + {} as unknown as config.TransportConfigJson + ) + ).toBeUndefined(); + }); +}); + +describe('setTransportHost', () => { + it('sets host', () => { + expect( + JSON.stringify( + config.setTransportConfigHost( + { + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + } as config.TransportConfigJson, + '1.2.3.4' + ) + ) + ).toEqual('{"endpoint":{"type":"dial","host":"1.2.3.4","port":443}}'); + expect( + JSON.stringify( + config.setTransportConfigHost( + { + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + } as config.TransportConfigJson, + '1:2::3' + ) + ) + ).toEqual('{"endpoint":{"type":"dial","host":"1:2::3","port":443}}'); + expect( + JSON.stringify( + config.setTransportConfigHost( + { + endpoint: { + type: 'dial', + host: '1.2.3.4', + port: 443, + }, + } as config.TransportConfigJson, + '1:2::3' + ) + ) + ).toEqual('{"endpoint":{"type":"dial","host":"1:2::3","port":443}}'); + }); + + it('fails on invalid config', () => { + expect( + config.setTransportConfigHost( + {} as unknown as config.TransportConfigJson, + '1:2::3' + ) + ).toBeUndefined(); + }); +}); 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..efb387191b --- /dev/null +++ b/client/src/www/app/outline_server_repository/config.ts @@ -0,0 +1,294 @@ +// 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'; + +interface DialEndpointJson { + type: 'dial'; + host: string; + port: number; + dialer?: DialerJson; +} + +function newDialEndpointJson(json: unknown): DialEndpointJson { + if (!(json instanceof Object)) { + throw new Error( + `dial endpoint config must be an object. Got ${typeof json}` + ); + } + if (!('host' in json)) { + throw new Error('missing host in endpoint config'); + } + if (typeof json.host !== 'string') { + throw new Error(`endpoint host must be a string. Got ${typeof json.host}`); + } + if (!('port' in json)) { + throw new Error('missing port in endpoint config'); + } + if (typeof json.port !== 'number') { + throw new Error(`endpoint port must be a number. Got ${typeof json.port}`); + } + + const dialerJson: DialEndpointJson = { + type: 'dial', + host: json.host, + port: json.port, + }; + if ('dialer' in json) { + dialerJson.dialer = newDialerJson(json.dialer); + } + return dialerJson; +} + +type EndpointJson = DialEndpointJson; + +function newEndpointJson(json: unknown): EndpointJson { + // A string is considered a direct dial to an address. + if (typeof json === 'string') { + const split = net.splitHostPort(json); + json = { + type: 'dial', + host: split.host, + port: split.port, + }; + } + if (!(json instanceof Object)) { + throw new Error(`endpoint config must be an object. Got ${typeof json}`); + } + if (!('type' in json)) { + json = {type: 'dial', ...json}; + throw new Error('endpoint config must have a type'); + } + switch (json.type) { + case 'dial': + return newDialEndpointJson(json); + default: + throw new Error(`invalid endpoint type ${json.type}`); + } +} + +interface ShadowsocksDialerJson { + type: 'shadowsocks'; + endpoint: EndpointJson; + cipher: string; + secret: string; + prefix?: string; +} + +function newShadowsocksDialerJson(json: unknown): ShadowsocksDialerJson { + if (!(json instanceof Object)) { + throw new Error(`dialer config must be an object. Got ${typeof json}`); + } + + if ('endpoint' in json) { + const endpoint = newEndpointJson(json.endpoint); + if (!('cipher' in json)) { + throw new Error('missing Shadowsocks cipher'); + } + if (typeof json.cipher !== 'string') { + throw new Error( + `Shadowsocks cipher must be a string. Got ${typeof json.cipher}` + ); + } + if (!('secret' in json)) { + throw new Error('missing Shadowsocks secret'); + } + if (typeof json.secret !== 'string') { + throw new Error( + `Shadowsocks secret must be a string. Got ${typeof json.secret}` + ); + } + const config: ShadowsocksDialerJson = { + type: 'shadowsocks', + endpoint, + cipher: json.cipher, + secret: json.secret, + }; + if ('prefix' in json) { + if (typeof json.prefix !== 'string') { + throw new Error( + `Shadowsocks prefix must be a string. Got ${typeof json.prefix}` + ); + } + config.prefix = json.prefix; + } + return config; + } else { + // Legacy format: https://shadowsocks.org/doc/configs.html#config-file. + if (!('server' in json)) { + throw new Error('missing Shadowsocks host'); + } + if (typeof json.server !== 'string') { + throw new Error( + `Shadowsocks server must be a string. Got ${typeof json.server}` + ); + } + if (!('server_port' in json)) { + throw new Error('missing Shadowsocks port'); + } + if (typeof json.server_port !== 'number') { + throw new Error( + `Shadowsocks port must be a number. Got ${typeof json.server_port}` + ); + } + if (!('method' in json)) { + throw new Error('missing Shadowsocks method'); + } + if (typeof json.method !== 'string') { + throw new Error( + `Shadowsocks cipher must be a string. Got ${typeof json.method}` + ); + } + if (!('password' in json)) { + throw new Error('missing Shadowsocks password'); + } + if (typeof json.password !== 'string') { + throw new Error( + `Shadowsocks password must be a string. Got ${typeof json.password}` + ); + } + const config: ShadowsocksDialerJson = { + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: json.server, + port: json.server_port, + }, + cipher: json.method, + secret: json.password, + }; + if ('prefix' in json) { + if (typeof json.prefix !== 'string') { + throw new Error( + `Shadowsocks prefix must be a string. Got ${typeof json.prefix}` + ); + } + config.prefix = json.prefix; + } + return config; + } +} + +type PipeDialerJson = { + type: 'pipe'; + dialers: DialerJson[]; +}; + +function newPipeDialerJson(json: unknown): PipeDialerJson { + if (!(json instanceof Object)) { + throw new Error(`pipe dialer config must be an object. Got ${typeof json}`); + } + if (!('dialers' in json) || !(json.dialers instanceof Array)) { + throw new Error('pipe dialer must have a dialers list'); + } + return { + type: 'pipe', + dialers: json.dialers.map(newDialerJson), + }; +} + +type DialerJson = PipeDialerJson | ShadowsocksDialerJson; + +function newDialerJson(json: unknown): DialerJson { + if (json instanceof Array) { + json = {type: 'pipe', dialers: json}; + } + if (!(json instanceof Object)) { + throw new Error(`dialer config must be an object. Got ${typeof json}`); + } + // Make Shadowsocks the default if the type is missing, for backwards-compatibility. + let type = 'shadowsocks'; + if ('type' in json) { + if (typeof json.type !== 'string') { + throw new Error('type must be a string'); + } + type = json.type; + } + switch (type) { + case 'pipe': + return newPipeDialerJson(json); + case 'shadowsocks': + return newShadowsocksDialerJson(json); + default: + throw new Error('invalid dialer config'); + } +} + +export type TransportConfigJson = DialerJson; +/** TunnelConfigJson represents the configuration to set up a tunnel. */ + +export interface TunnelConfigJson { + /** transport describes how to establish connections to the destinations. */ + transport: TransportConfigJson; +} + +/** tunnelConfigFromJson creates a TunnelConfigJson from the given JSON object. */ +export function newTunnelJson(json: unknown): TunnelConfigJson { + if (!(json instanceof Object)) { + throw new Error(`tunnel config must be an object. Got ${typeof json}`); + } + if ('transport' in json) { + return { + transport: newDialerJson(json.transport), + }; + } else { + // Fallback to considering the object a TransportConfig if "transport" is not present. + return { + transport: newDialerJson(json), + }; + } +} + +/** + * 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 { + if ('endpoint' in transport && transport.endpoint.type === 'dial') { + return net.joinHostPort( + transport.endpoint.host, + `${transport.endpoint.port}` + ); + } + 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 { + if ('endpoint' in transport && transport.endpoint.type === 'dial') { + return transport.endpoint.host; + } + 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. + */ +export function setTransportConfigHost( + transport: TransportConfigJson, + newHost: string +): TransportConfigJson | undefined { + if ('endpoint' in transport && transport.endpoint.type === 'dial') { + return {...transport, endpoint: {...transport.endpoint, host: newHost}}; + } +} diff --git a/client/src/www/app/outline_server_repository/server.spec.ts b/client/src/www/app/outline_server_repository/server.spec.ts index b86cd0090e..8dbfc87fa8 100644 --- a/client/src/www/app/outline_server_repository/server.spec.ts +++ b/client/src/www/app/outline_server_repository/server.spec.ts @@ -22,10 +22,14 @@ describe('parseTunnelConfigJson', () => { ) ).toEqual({ transport: { - host: 'example.com', - port: 443, - method: 'METHOD', - password: 'PASSWORD', + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: 'PASSWORD', }, }); }); @@ -37,10 +41,14 @@ describe('parseTunnelConfigJson', () => { ) ).toEqual({ transport: { - host: 'example.com', - port: 443, - method: 'METHOD', - password: 'PASSWORD', + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: 'example.com', + port: 443, + }, + cipher: 'METHOD', + secret: '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 e3b9d73854..4a97704fb1 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -16,13 +16,8 @@ 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 {TunnelConfigJson, TransportConfigJson, getAddressFromTransportConfig} from './config'; +import {VpnApi, StartRequestJson} from './vpn'; import * as errors from '../../model/errors'; import {PlatformError} from '../../model/platform_error'; import {Server, ServerType} from '../../model/server'; @@ -149,13 +144,17 @@ function parseTunnelConfigJson(responseBody: string): TunnelConfigJson | null { } const transport: TransportConfigJson = { - host: responseJson.server, - port: responseJson.server_port, - method: responseJson.method, - password: responseJson.password, + type: 'shadowsocks', + endpoint: { + type: 'dial', + host: responseJson.server, + port: responseJson.server_port, + }, + cipher: responseJson.method, + secret: responseJson.password, }; if (responseJson.prefix) { - (transport as {prefix?: string}).prefix = responseJson.prefix; + transport.prefix = responseJson.prefix; } return { transport, 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/infrastructure/net.spec.ts b/infrastructure/net.spec.ts index 71939b78be..2267daa7ad 100644 --- a/infrastructure/net.spec.ts +++ b/infrastructure/net.spec.ts @@ -22,3 +22,44 @@ describe('joinHostPort', () => { expect(net.joinHostPort('8example.com', '443')).toEqual('8example.com:443'); }); }); + +describe('splitHostAndPort', () => { + it('should split IPv4 host and port', () => { + const input = '192.168.1.100:3000'; + const expected = {host: '192.168.1.100', port: 3000}; + expect(net.splitHostPort(input)).toEqual(expected); + }); + + it('should split hostname and port', () => { + const input = 'localhost:8080'; + const expected = {host: 'localhost', port: 8080}; + expect(net.splitHostPort(input)).toEqual(expected); + }); + + it('should split IPv6 host and port', () => { + const input = '[2001:db8::1]:80'; + const expected = {host: '2001:db8::1', port: 80}; + expect(net.splitHostPort(input)).toEqual(expected); + }); + + it('should split IPv6 host and port without brackets', () => { + const input = '2001:db8::1:80'; + const expected = {host: '2001:db8::1', port: 80}; + expect(net.splitHostPort(input)).toEqual(expected); + }); + + it('should return null for invalid input', () => { + const input = 'invalid-input'; + expect(net.splitHostPort(input)).toBeNull(); + }); + + it('should return null for missing port', () => { + const input = 'localhost'; + expect(net.splitHostPort(input)).toBeNull(); + }); + + it('should return null for invalid port', () => { + const input = 'localhost:abc'; + expect(net.splitHostPort(input)).toBeNull(); + }); +}); diff --git a/infrastructure/net.ts b/infrastructure/net.ts index 014b6d7d7c..d1b1d23fe7 100644 --- a/infrastructure/net.ts +++ b/infrastructure/net.ts @@ -19,3 +19,14 @@ export function joinHostPort(host: string, port: string): string { return `${host}:${port}`; } } + +export function splitHostPort( + address: string +): {host: string; port: number} | null { + const match = address.match(/^\[?([^\]]+)\]?:(\d+)$/); + if (match) { + return {host: match[1], port: parseInt(match[2])}; + } else { + return null; + } +}