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

feat(client/electron): propagate structured errors from Go to TypeScript #2033

Merged
merged 21 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
93 changes: 60 additions & 33 deletions client/electron/go_vpn_tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {ChildProcessHelper, ProcessTerminatedExitCodeError, ProcessTerminatedSig
import {RoutingDaemon} from './routing_service';
import {VpnTunnel} from './vpn_tunnel';
import {ShadowsocksSessionConfig, TunnelStatus} from '../src/www/app/tunnel';
import {ErrorCode, fromErrorCode, UnexpectedPluginError} from '../src/www/model/errors';
import {ErrorCode} from '../src/www/model/errors';

const isLinux = platform() === 'linux';
const isWindows = platform() === 'win32';
Expand All @@ -47,6 +47,7 @@ const DNS_RESOLVERS = ['1.1.1.1', '9.9.9.9'];
// about the others.
export class GoVpnTunnel implements VpnTunnel {
private readonly tun2socks: GoTun2socks;
private readonly connectivityChecker: GoTun2socks;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this type GoTun2socks? Why do we need two?

Copy link
Contributor Author

@jyyi1 jyyi1 Jun 18, 2024

Choose a reason for hiding this comment

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

Because we are invoking the same binary with different arguments. And these two processes might run at the same time. For example, we will check connectivity here, and tun2socks is still running until we stop it later.


// See #resumeListener.
private disconnected = false;
Expand All @@ -62,6 +63,7 @@ export class GoVpnTunnel implements VpnTunnel {

constructor(private readonly routing: RoutingDaemon, private config: ShadowsocksSessionConfig) {
this.tun2socks = new GoTun2socks(config);
this.connectivityChecker = new GoTun2socks(config);

// This promise, tied to both helper process' exits, is key to the instance's
// lifecycle:
Expand All @@ -79,6 +81,7 @@ export class GoVpnTunnel implements VpnTunnel {
// processes
enableDebugMode() {
this.tun2socks.enableDebugMode();
this.connectivityChecker.enableDebugMode();
}

// Fulfills once all three helpers have started successfully.
Expand All @@ -96,15 +99,12 @@ export class GoVpnTunnel implements VpnTunnel {
});

if (checkProxyConnectivity) {
this.isUdpEnabled = await checkConnectivity(this.config);
this.isUdpEnabled = await checkConnectivity(this.connectivityChecker);
}
console.log(`UDP support: ${this.isUdpEnabled}`);

// Don't await here because we want to launch both binaries
this.tun2socks.start(this.isUdpEnabled);

console.log('starting routing daemon');
await this.routing.start();
await Promise.all([this.tun2socks.start(this.isUdpEnabled), this.routing.start()]);
}

networkChanged(status: TunnelStatus) {
Expand All @@ -131,24 +131,24 @@ export class GoVpnTunnel implements VpnTunnel {
console.log('stopped tun2socks in preparation for suspend');
}

private resumeListener() {
private async resumeListener() {
if (this.disconnected) {
// NOTE: Cannot remove resume listeners - Electron bug?
console.error('resume event invoked but this tunnel is terminated - doing nothing');
return;
}

console.log('restarting tun2socks after resume');
this.tun2socks.start(this.isUdpEnabled);

// Check if UDP support has changed; if so, silently restart.
this.updateUdpSupport();
await Promise.all([
this.tun2socks.start(this.isUdpEnabled),
this.updateUdpSupport(), // Check if UDP support has changed; if so, silently restart.
]);
}

private async updateUdpSupport() {
const wasUdpEnabled = this.isUdpEnabled;
try {
this.isUdpEnabled = await checkConnectivity(this.config);
this.isUdpEnabled = await checkConnectivity(this.connectivityChecker);
} catch (e) {
console.error(`connectivity check failed: ${e}`);
return;
Expand All @@ -161,7 +161,7 @@ export class GoVpnTunnel implements VpnTunnel {

// Restart tun2socks.
await this.tun2socks.stop();
this.tun2socks.start(this.isUdpEnabled);
await this.tun2socks.start(this.isUdpEnabled);
}

// Use #onceDisconnected to be notified when the tunnel terminates.
Expand Down Expand Up @@ -216,13 +216,22 @@ export class GoVpnTunnel implements VpnTunnel {
// outline-go-tun2socks is a Go program that processes IP traffic from a TUN/TAP device
// and relays it to a Shadowsocks proxy server.
class GoTun2socks {
// Resolved when Tun2socks prints "tun2socks running" to stdout
// Call `monitorStarted` to set this field
private whenStarted: Promise<void>;
private stopRequested = false;
private readonly process: ChildProcessHelper;

constructor(private readonly config: ShadowsocksSessionConfig) {
this.process = new ChildProcessHelper(pathToEmbeddedTun2socksBinary());
}

/**
* Starts tun2socks process, and waits for it to launch successfully.
* Success is confirmed when the phrase "tun2socks running" is detected in the `stdout`.
* Otherwise, an error containing a JSON-formatted message will be thrown.
* @param isUdpEnabled Indicates whether the remote Shadowsocks server supports UDP.
*/
async start(isUdpEnabled: boolean): Promise<void> {
// ./tun2socks.exe \
// -tunName outline-tap0 -tunDNS 1.1.1.1,9.9.9.9 \
Expand All @@ -246,27 +255,46 @@ class GoTun2socks {
args.push('-dnsFallback');
}

this.stopRequested = false;
let autoRestart = false;
do {
if (autoRestart) {
console.warn(`tun2socks exited unexpectedly. Restarting...`);
}
autoRestart = false;
this.process.onStdErr = (data?: string | Buffer) => {
const whenProcessEnded = this.launchWithAutoRestart(args);

// Either started successfully, or terminated exceptionally
return Promise.race([this.whenStarted, whenProcessEnded]);
}

private monitorStarted(): Promise<void> {
return (this.whenStarted = new Promise(resolve => {
this.process.onStdOut = (data?: string | Buffer) => {
if (data?.toString().includes('tun2socks running')) {
sbruens marked this conversation as resolved.
Show resolved Hide resolved
console.debug('tun2socks started');
autoRestart = true;
this.process.onStdErr = undefined;
console.debug('[tun2socks] - started');
this.process.onStdOut = null;
resolve();
}
};
}));
}

private async launchWithAutoRestart(args: string[]): Promise<void> {
console.debug('[tun2socks] - starting to route network traffic ...');
let restarting = false;
let lastError: Error | null = null;
do {
if (restarting) {
console.warn('[tun2socks] - exited unexpectedly; restarting ...');
}
restarting = false;
this.monitorStarted().then(() => (restarting = true));
try {
lastError = null;
await this.process.launch(args);
console.info('tun2socks exited with no errors');
console.info('[tun2socks] - exited with no errors');
} catch (e) {
console.error(`tun2socks terminated due to ${e}`);
console.error('[tun2socks] - terminated due to:', e);
lastError = e;
}
} while (!this.stopRequested && autoRestart);
} while (!this.stopRequested && restarting);
if (lastError) {
throw lastError;
}
}

stop() {
Expand All @@ -280,7 +308,7 @@ class GoTun2socks {
* -tun* and -dnsFallback options have no effect on this mode.
*/
checkConnectivity() {
console.debug('using tun2socks to check connectivity');
console.debug('[tun2socks] - checking connectivity ...');
return this.process.launch([
'-proxyHost',
this.config.host || '',
Expand All @@ -305,18 +333,17 @@ class GoTun2socks {
// `config`. Checks whether proxy server is reachable, whether the network and proxy support UDP
// forwarding and validates the proxy credentials. Resolves with a boolean indicating whether UDP
// forwarding is supported. Throws if the checks fail or if the process fails to start.
async function checkConnectivity(config: ShadowsocksSessionConfig) {
async function checkConnectivity(tun2socks: GoTun2socks) {
try {
await new GoTun2socks(config).checkConnectivity();
await tun2socks.checkConnectivity();
return true;
} catch (e) {
console.error(`connectivity check error: ${e}`);
console.error('connectivity check error:', e);
if (e instanceof ProcessTerminatedExitCodeError) {
if (e.exitCode === ErrorCode.UDP_RELAY_NOT_ENABLED) {
return false;
}
throw fromErrorCode(e.exitCode);
}
throw new UnexpectedPluginError();
throw e;
}
}
20 changes: 12 additions & 8 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import autoLaunch = require('auto-launch'); // tslint:disable-line
import {app, BrowserWindow, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell, Tray} from 'electron';
import {autoUpdater} from 'electron-updater';

import {lookupIp} from "./connectivity";
import {lookupIp} from './connectivity';
import {GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
import {TunnelStore, SerializableTunnel} from './tunnel_store';
Expand Down Expand Up @@ -142,7 +142,7 @@ function setupWindow(): void {
// The ideal solution would be: either electron-builder supports the app icon; or we add
// dpi-aware features to this app.
if (isLinux) {
mainWindow.setIcon(path.join(app.getAppPath(), 'output', 'client', 'electron', 'icons', 'png', '64x64.png'));
mainWindow.setIcon(path.join(app.getAppPath(), 'client', 'electron', 'icons', 'png', '64x64.png'));
}

const pathToIndexHtml = path.join(app.getAppPath(), 'client', 'www', 'index_electron.html');
Expand Down Expand Up @@ -450,11 +450,17 @@ function main() {
mainWindow?.webContents.send('outline-ipc-push-clipboard');
});

// Connects to the specified server.
// Connects to a proxy server specified by a config.
//
// If any issues occur, an Error will be thrown, which you can try-catch around
// `ipcRenderer.invoke`. But you should avoid depending on the specific error type.
// Instead, you should use its message property (which would probably be a JSON representation
// of a PlatformError). See https://github.com/electron/electron/issues/24427.
//
// TODO: refactor channel name and namespace to a constant
ipcMain.handle(
'outline-ipc-start-proxying',
async (_, args: {config: ShadowsocksSessionConfig; id: string}): Promise<errors.ErrorCode> => {
async (_, args: {config: ShadowsocksSessionConfig; id: string}): Promise<void> => {
// TODO: Rather than first disconnecting, implement a more efficient switchover (as well as
// being faster, this would help prevent traffic leaks - the Cordova clients already do
// this).
Expand All @@ -480,13 +486,11 @@ function main() {
tunnelStore.save(args).catch(() => {
console.error('Failed to store tunnel.');
});

return errors.ErrorCode.NO_ERROR;
} catch (e) {
console.error(`could not connect: ${e.name} (${e.message})`);
console.error('could not connect:', e);
// clean up the state, no need to await because stopVpn might throw another error which can be ignored
stopVpn();
return errors.toErrorCode(e);
throw e;
}
}
);
Expand Down
21 changes: 19 additions & 2 deletions client/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,25 @@ export class ElectronRendererMethodChannel {

// TODO: replace the `any` with a better type once we unify the IPC call framework
/* eslint-disable @typescript-eslint/no-explicit-any */
readonly invoke = (channel: string, ...args: unknown[]): Promise<any> =>
ipcRenderer.invoke(`${this.namespace}-${channel}`, ...args);
readonly invoke = async (channel: string, ...args: unknown[]): Promise<any> => {
const ipcName = `${this.namespace}-${channel}`;
try {
await ipcRenderer.invoke(ipcName, ...args);
} catch (e) {
// Normalize the error message to what's being thrown in the IPC itself
// e.message == "Error invoking remote method 'xxx': <error name>: <actual message>"
// https://github.com/electron/electron/blob/v31.0.0/lib/renderer/api/ipc-renderer.ts#L22
if (typeof e?.message === 'string') {
const errPattern = new RegExp(`'${ipcName}':\\s*(?<name>[^:]+):\\s*(?<message>.*)`, 's');
const groups = e.message.match(errPattern)?.groups;
if (typeof groups?.['name'] === 'string' && typeof groups?.['message'] === 'string') {
e.name = groups['name'];
e.message = groups['message'];
}
}
throw e;
}
};

readonly on = (channel: string, listener: (e: IpcRendererEvent, ...args: unknown[]) => void): void => {
ipcRenderer.on(`${this.namespace}-${channel}`, listener);
Expand Down
Loading
Loading