Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web app online config #921

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions cordova-plugin-outline/outlinePlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ function quitApplication() {
exec(function() {}, function() {}, PLUGIN_NAME, 'quitApplication', []);
}

var globalId = 100; // Internal, incremental ID.

// This must be kept in sync with:
// - cordova-plugin-outline/android/java/org/outline/OutlinePlugin.java#ErrorCode
// - cordova-plugin-outline/apple/src/OutlineVpn.swift#ErrorCode
// - cordova-plugin-outline/apple/vpn/PacketTunnelProvider.h#NS_ENUM
// - www/model/errors.ts
Expand All @@ -56,7 +55,11 @@ const ERROR_CODE = {
CONFIGURE_SYSTEM_PROXY_FAILURE: 9,
NO_ADMIN_PERMISSIONS: 10,
UNSUPPORTED_ROUTING_TABLE: 11,
SYSTEM_MISCONFIGURED: 12
SYSTEM_MISCONFIGURED: 12,
DNS_RESOLUTION_ERROR: 13,
TLS_CERTIFICATE_ERROR: 14,
HTTP_ERROR: 15,
UNSUPPORTED_SERVER_CONFIGURATION: 16
};

// This must be kept in sync with the TypeScript definition:
Expand All @@ -65,22 +68,16 @@ function OutlinePluginError(errorCode) {
this.errorCode = errorCode || ERROR_CODE.UNEXPECTED;
}

// This must be kept in sync with the TypeScript definition:
// www/app/tunnel.ts
const TunnelStatus = {
CONNECTED: 0,
DISCONNECTED: 1,
RECONNECTING: 2
}

function Tunnel(config, id) {
if (id) {
this.id_ = id.toString();
} else {
this.id_ = (globalId++).toString();
}

if (!config) {
throw new Error('Server configuration is required');
}
function Tunnel(id, config) {
this.id = id;
this.config = config;
}

Expand All @@ -89,15 +86,25 @@ Tunnel.prototype._promiseExec = function(cmd, args) {
const rejectWithError = function(errorCode) {
reject(new OutlinePluginError(errorCode));
};
exec(resolve, rejectWithError, PLUGIN_NAME, cmd, [this.id_].concat(args));
exec(resolve, rejectWithError, PLUGIN_NAME, cmd, [this.id].concat(args));
}.bind(this));
};

Tunnel.prototype._exec = function(cmd, args, success, error) {
exec(success, error, PLUGIN_NAME, cmd, [this.id_].concat(args));
exec(success, error, PLUGIN_NAME, cmd, [this.id].concat(args));
};

Tunnel.prototype.fetchProxyConfig = function(configSource) {
if (!configSource) {
throw new OutlinePluginError(ERROR_CODE.ILLEGAL_SERVER_CONFIGURATION);
}
return this._promiseExec('fetchProxyConfig', [configSource]);
};

Tunnel.prototype.start = function() {
if (!this.config) {
throw new OutlinePluginError(ERROR_CODE.ILLEGAL_SERVER_CONFIGURATION);
}
return this._promiseExec('start', [this.config]);
};

Expand All @@ -110,12 +117,15 @@ Tunnel.prototype.isRunning = function() {
};

Tunnel.prototype.isReachable = function() {
if (!this.config) {
return new Promise.reject(new OutlinePluginError(ERROR_CODE.ILLEGAL_SERVER_CONFIGURATION));
}
return this._promiseExec('isReachable', [this.config.host, this.config.port]);
};

Tunnel.prototype.onStatusChange = function(listener) {
const onError = function(err) {
console.warn('Failed to execute disconnect listener', err);
console.warn('failed to execute status change listener', err);
};
this._exec('onStatusChange', [], listener, onError);
};
Expand Down
11 changes: 7 additions & 4 deletions src/types/ambient/outlinePlugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

declare type Tunnel = import('../../www/app/tunnel').Tunnel;
declare type TunnelStatus = import('../../www/app/tunnel').TunnelStatus;
declare type ProxyConfigResponse = import('../../www/app/tunnel').ProxyConfigResponse;
declare type ShadowsocksConfig = import('../../www/model/shadowsocks').ShadowsocksConfig;
declare type ShadowsocksConfigSource =
import('../../www/model/shadowsocks').ShadowsocksConfigSource;

declare namespace cordova.plugins.outline {
const log: {
Expand All @@ -34,14 +37,14 @@ declare namespace cordova.plugins.outline {

// Implements the Tunnel interface with native functionality.
class Tunnel implements Tunnel {
// Creates a new instance with `config`.
// A sequential ID will be generated if `id` is absent.
constructor(config: ShadowsocksConfig, id?: string);
constructor(id: string, config?: ShadowsocksConfig);

config: ShadowsocksConfig;
config?: ShadowsocksConfig;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this shouldn't be explosed?


readonly id: string;

fetchProxyConfig(source: ShadowsocksConfigSource): Promise<ProxyConfigResponse>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would go away, since the TS code would already be giving you the Shadowsocks config to use.


start(): Promise<void>;

stop(): Promise<void>;
Expand Down
85 changes: 62 additions & 23 deletions src/www/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {SHADOWSOCKS_URI} from 'ShadowsocksConfig/shadowsocks_config';

import * as errors from '../model/errors';
import * as events from '../model/events';
import {Server} from '../model/server';
import {Server, ServerConfig} from '../model/server';

import {Clipboard} from './clipboard';
import {EnvironmentVariables} from './environment';
Expand Down Expand Up @@ -115,6 +115,8 @@ export class App {
this.eventQueue.subscribe(events.ServerConnected, this.showServerConnected.bind(this));
this.eventQueue.subscribe(events.ServerDisconnected, this.showServerDisconnected.bind(this));
this.eventQueue.subscribe(events.ServerReconnecting, this.showServerReconnecting.bind(this));
this.eventQueue.subscribe(
events.ServerConfigSourceUrlChanged, this.updateServerConfigSourceUrl.bind(this));

this.eventQueue.startPublishing();

Expand Down Expand Up @@ -175,6 +177,8 @@ export class App {
} else {
messageKey = 'error-unexpected';
}
// TODO(alalama): messages, l10n for UnsupportedServerConfiguration, TlsCertificateError,
// DomainResolutionError, HttpError

const message =
messageParams ? this.localize(messageKey, ...messageParams) : this.localize(messageKey);
Expand Down Expand Up @@ -220,6 +224,14 @@ export class App {
card.state = 'RECONNECTING';
}

private updateServerConfigSourceUrl(event: events.ServerConfigSourceUrlChanged) {
console.debug(`server ${event.server.id} config changed`);
// Casting is safe because only OutlineServer emits this event.
const config = (event.server as PersistentServer).config;
config.source = {url: event.url};
this.serverRepo.update(event.server.id, config);
}

private displayZeroStateUi() {
if (this.rootEl.$.serversView.shouldShowZeroState) {
this.rootEl.$.addServerView.openAddServerSheet();
Expand Down Expand Up @@ -283,6 +295,7 @@ export class App {
try {
this.serverRepo.add(event.detail.serverConfig);
} catch (err) {
console.error(`failed to add server: ${err}`);
this.changeToDefaultPage();
this.showLocalizedError(err);
}
Expand All @@ -303,11 +316,46 @@ export class App {
private confirmAddServer(accessKey: string, fromClipboard = false) {
const addServerView = this.rootEl.$.addServerView;
accessKey = unwrapInvite(accessKey);
if (fromClipboard && accessKey in this.ignoredAccessKeys) {
return console.debug('Ignoring access key');
} else if (fromClipboard && addServerView.isAddingServer()) {
return console.debug('Already adding a server');
if (!accessKey) {
return console.warn('Attempted to add an empty access key');
}
if (fromClipboard) {
if (accessKey in this.ignoredAccessKeys) {
return console.debug('Ignoring access key');
} else if (addServerView.isAddingServer()) {
return console.debug('Already adding a server');
} else if (!accessKey.startsWith('ss://')) {
return console.debug('Ignoring non ss: URL from clipboard');
}
}

let serverConfig: ServerConfig;
// TODO(alalama): support ssconf://?
if (accessKey.startsWith('https://') && new URL(accessKey)) {
serverConfig = {source: {url: accessKey}};
// TODO(alalama): refine name, l10n
serverConfig.name = 'Dynamic Server';
} else {
serverConfig = {proxy: this.parseShadowsocksAccessKey(accessKey)};
}

if (!this.serverRepo.containsServer(serverConfig)) {
// Only prompt the user to add new servers.
try {
addServerView.openAddServerConfirmationSheet(accessKey, serverConfig);
} catch (err) {
console.error('Failed to open add sever confirmation sheet:', err.message);
if (!fromClipboard) this.showLocalizedError();
}
} else if (!fromClipboard) {
// Display error message if this is not a clipboard add.
addServerView.close();
this.showLocalizedError(new errors.ServerAlreadyAdded(
this.serverRepo.createServer('', serverConfig, this.eventQueue)));
}
}

private parseShadowsocksAccessKey(accessKey: string): ShadowsocksConfig {
// Expect SHADOWSOCKS_URI.parse to throw on invalid access key; propagate any exception.
let shadowsocksConfig = null;
try {
Expand All @@ -323,27 +371,13 @@ export class App {
this.localize('server-default-name-outline') :
shadowsocksConfig.tag.data ? shadowsocksConfig.tag.data :
this.localize('server-default-name');
const serverConfig = {
return {
host: shadowsocksConfig.host.data,
port: shadowsocksConfig.port.data,
method: shadowsocksConfig.method.data,
password: shadowsocksConfig.password.data,
name,
};
if (!this.serverRepo.containsServer(serverConfig)) {
// Only prompt the user to add new servers.
try {
addServerView.openAddServerConfirmationSheet(accessKey, serverConfig);
} catch (err) {
console.error('Failed to open add sever confirmation sheet:', err.message);
if (!fromClipboard) this.showLocalizedError();
}
} else if (!fromClipboard) {
// Display error message if this is not a clipboard add.
addServerView.close();
this.showLocalizedError(new errors.ServerAlreadyAdded(
this.serverRepo.createServer('', serverConfig, this.eventQueue)));
}
}

private async forgetServer(event: CustomEvent) {
Expand Down Expand Up @@ -379,20 +413,24 @@ export class App {
const card = this.getCardByServerId(serverId);

console.log(`connecting to server ${serverId}`);

card.state = 'CONNECTING';
try {
await server.connect();
card.state = 'CONNECTED';
card.serverHost = server.host;
console.log(`connected to server ${serverId}`);
this.rootEl.showToast(this.localize('server-connected', 'serverName', server.name));
this.maybeShowAutoConnectDialog();
} catch (e) {
card.state = 'DISCONNECTED';
this.showLocalizedError(e);
console.error(`could not connect to server ${serverId}: ${e.name}`);
console.error(`could not connect to server ${serverId}: ${e}`);
if (!(e instanceof errors.RegularNativeError)) {
this.errorReporter.report(`connection failure: ${e.name}`, 'connection-failure');
try {
await this.errorReporter.report(`connection failure: ${e.name}`, 'connection-failure');
} catch (err) {
console.error(`failed to submit error report: ${err}`);
}
}
}
}
Expand Down Expand Up @@ -428,6 +466,7 @@ export class App {
try {
await server.disconnect();
card.state = 'DISCONNECTED';
card.serverHost = server.host;
console.log(`disconnected from server ${serverId}`);
this.rootEl.showToast(this.localize('server-disconnected', 'serverName', server.name));
} catch (e) {
Expand Down
4 changes: 2 additions & 2 deletions src/www/app/cordova_main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ class CordovaPlatform implements OutlinePlatform {
return (serverId: string, config: ServerConfig, eventQueue: EventQueue) => {
return new OutlineServer(
serverId, config,
this.hasDeviceSupport() ? new cordova.plugins.outline.Tunnel(config, serverId) :
new FakeOutlineTunnel(config, serverId),
this.hasDeviceSupport() ? new cordova.plugins.outline.Tunnel(serverId, config.proxy) :
new FakeOutlineTunnel(serverId, config.proxy),
eventQueue);
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/www/app/electron_main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ main({
return (serverId: string, config: ServerConfig, eventQueue: EventQueue) => {
return new OutlineServer(
serverId, config,
isOsSupported ? new ElectronOutlineTunnel(config, serverId) :
new FakeOutlineTunnel(config, serverId),
isOsSupported ? new ElectronOutlineTunnel(serverId, config.proxy) :
new FakeOutlineTunnel(serverId, config.proxy),
eventQueue);
};
},
Expand Down
17 changes: 14 additions & 3 deletions src/www/app/electron_outline_tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ import {ipcRenderer} from 'electron';
import * as promiseIpc from 'electron-promise-ipc';

import * as errors from '../model/errors';
import {ShadowsocksConfig} from '../model/shadowsocks';
import {ShadowsocksConfig, ShadowsocksConfigSource} from '../model/shadowsocks';

import {Tunnel, TunnelStatus} from './tunnel';
import {ProxyConfigResponse, Tunnel, TunnelStatus} from './tunnel';

export class ElectronOutlineTunnel implements Tunnel {
private statusChangeListener: ((status: TunnelStatus) => void)|null = null;

private running = false;

constructor(public config: ShadowsocksConfig, public id: string) {
constructor(public id: string, public config?: ShadowsocksConfig) {
// This event is received when the proxy connects. It is mainly used for signaling the UI that
// the proxy has been automatically connected at startup (if the user was connected at shutdown)
ipcRenderer.on(`proxy-connected-${this.id}`, (e: Event) => {
Expand All @@ -37,6 +37,17 @@ export class ElectronOutlineTunnel implements Tunnel {
});
}

async fetchProxyConfig(source: ShadowsocksConfigSource): Promise<ProxyConfigResponse> {
try {
return promiseIpc.send('fetch-proxy-config', {source});
} catch (e) {
if (typeof e === 'number') {
throw new errors.OutlinePluginError(e);
}
throw e;
}
}

async start() {
if (this.running) {
return Promise.resolve();
Expand Down
26 changes: 21 additions & 5 deletions src/www/app/fake_tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,40 @@
// limitations under the License.

import * as errors from '../model/errors';
import {ShadowsocksConfig} from '../model/shadowsocks';
import {ShadowsocksConfig, ShadowsocksConfigSource} from '../model/shadowsocks';

import {Tunnel, TunnelStatus} from './tunnel';
import {ProxyConfigResponse, Tunnel, TunnelStatus} from './tunnel';

// Fake Tunnel implementation for demoing and testing.
// Note that because this implementation does not emit disconnection events, "switching" between
// servers in the server list will not work as expected.
export class FakeOutlineTunnel implements Tunnel {
private running = false;

constructor(public config: ShadowsocksConfig, public id: string) {}
constructor(public id: string, public config?: ShadowsocksConfig) {}

private playBroken() {
return this.config.name?.toLowerCase().includes('broken');
return this.config ?.name ?.toLowerCase().includes('broken');
}

private playUnreachable() {
return this.config.name?.toLowerCase().includes('unreachable');
return this.config ?.name ?.toLowerCase().includes('unreachable');
}

async fetchProxyConfig(source: ShadowsocksConfigSource): Promise<ProxyConfigResponse> {
if (!source) {
throw new errors.IllegalServerConfiguration();
}
return {
statusCode: 200,
proxies: [{
host: '127.0.0.1',
port: 1080,
password: 'test',
method: 'chacha20-ietf-poly1305',
name: 'Dynamic Server'
}]
};
}

async start(): Promise<void> {
Expand Down
Loading