Skip to content

Commit

Permalink
feat: salt prefix support (#1454)
Browse files Browse the repository at this point in the history
* WIP prefix support

This is working in Electron, untested on Android, and
unimplemented on macOS/iOS.

* Update for Jigsaw-Code/outline-go-tun2socks#98

* Fix JSON conversion issue on Android

* Add APK for testing

* Add initial Apple platform support.  Compiles, untested.

* Remove spurious changes and move APK to the root to make the CI happy

* Fix unicode to uint8 conversion

* s/uint8/uint8_t/ as required by iOS

* Add nil prefix check

* Add log message

* Switch UTF-8 on Electron, reject out of range codepoints

* Move prefix parsing from Objective C to Swift

* Update binaries to match outline-go-tun2socks v3.0.0 exactly

* Remove testing APK to avoid merging it
  • Loading branch information
Benjamin M. Schwartz committed Nov 10, 2022
1 parent 03ec3cb commit bd1fc96
Show file tree
Hide file tree
Showing 48 changed files with 594 additions and 709 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ parcelable ShadowsocksConfig {
int port;
String password;
String method;
@nullable byte[] prefix;
}
18 changes: 8 additions & 10 deletions cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnel.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import java.util.logging.Logger;
import tun2socks.OutlineTunnel;
import tun2socks.Tun2socks;
import org.outline.shadowsocks.ShadowsocksConfig;


/**
* Manages the life-cycle of the system VPN, and of the tunnel that processes its traffic.
Expand Down Expand Up @@ -120,20 +122,17 @@ public synchronized void tearDownVpn() {
/**
* Connects a tunnel between a Shadowsocks proxy server and the VPN TUN interface.
*
* @param host is IP address of the SOCKS proxy server.
* @param port is the port of the SOCKS proxy server.
* @param password is the password of the Shadowsocks proxy.
* @param cipher is the encryption cipher used by the Shadowsocks proxy.
* @param client provides access to the Shadowsocks proxy.
* @param isUdpEnabled conveys the result of UDP probing. TODO: Roll this into `client`.
* @throws IllegalArgumentException if |socksServerAddress| is null.
* @throws IllegalStateException if the VPN has not been established, or the tunnel is already
* connected.
* @throws Exception when the tunnel fails to connect.
*/
public synchronized void connectTunnel(final String host, int port, final String password,
final String cipher, boolean isUdpEnabled) throws Exception {
public synchronized void connectTunnel(final shadowsocks.Client client, boolean isUdpEnabled) throws Exception {
LOG.info("Connecting the tunnel.");
if (host == null || port <= 0 || port > 65535 || password == null || cipher == null) {
throw new IllegalArgumentException("Must provide valid Shadowsocks proxy parameters.");
if (client == null) {
throw new IllegalArgumentException("Must provide a Shadowsocks client.");
}
if (tunFd == null) {
throw new IllegalStateException("Must establish the VPN before connecting the tunnel.");
Expand All @@ -143,8 +142,7 @@ public synchronized void connectTunnel(final String host, int port, final String
}

LOG.fine("Starting tun2socks...");
tunnel = Tun2socks.connectShadowsocksTunnel(
tunFd.getFd(), host, port, password, cipher, isUdpEnabled);
tunnel = Tun2socks.connectShadowsocksTunnel(tunFd.getFd(), client, isUdpEnabled);
}

/* Disconnects a tunnel created by a previous call to |connectTunnel|. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,29 @@ public static TunnelConfig makeTunnelConfig(final String tunnelId, final JSONObj
tunnelConfig.proxy.port = config.getInt("port");
tunnelConfig.proxy.password = config.getString("password");
tunnelConfig.proxy.method = config.getString("method");
// `name` and `prefix` are optional properties.
try {
// `name` is an optional property; don't throw if it fails to parse.
tunnelConfig.name = config.getString("name");
} catch (JSONException e) {
LOG.fine("Tunnel config missing name");
}
String prefix = null;
try {
prefix = config.getString("prefix");
LOG.fine("Activating experimental prefix support");
} catch (JSONException e) {
// pass
}
if (prefix != null) {
tunnelConfig.proxy.prefix = new byte[prefix.length()];
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
if ((c & 0xFF) != c) {
throw new JSONException(String.format("Prefix character '%c' is out of range", c));
}
tunnelConfig.proxy.prefix[i] = (byte)c;
}
}
return tunnelConfig;
}

Expand Down Expand Up @@ -201,13 +218,27 @@ private synchronized OutlinePlugin.ErrorCode startTunnel(
}
}

final ShadowsocksConfig proxyConfig = config.proxy;
final shadowsocks.Config configCopy = new shadowsocks.Config();
configCopy.setHost(config.proxy.host);
configCopy.setPort(config.proxy.port);
configCopy.setCipherName(config.proxy.method);
configCopy.setPassword(config.proxy.password);
configCopy.setPrefix(config.proxy.prefix);
final shadowsocks.Client client;
try {
client = new shadowsocks.Client(configCopy);
} catch (Exception e) {
LOG.log(Level.WARNING, "Invalid configuration", e);
tearDownActiveTunnel();
return OutlinePlugin.ErrorCode.ILLEGAL_SERVER_CONFIGURATION;
}

OutlinePlugin.ErrorCode errorCode = OutlinePlugin.ErrorCode.NO_ERROR;
if (!isAutoStart) {
try {
// Do not perform connectivity checks when connecting on startup. We should avoid failing
// the connection due to a network error, as network may not be ready.
errorCode = checkServerConnectivity(proxyConfig);
errorCode = checkServerConnectivity(client);
if (!(errorCode == OutlinePlugin.ErrorCode.NO_ERROR
|| errorCode == OutlinePlugin.ErrorCode.UDP_RELAY_NOT_ENABLED)) {
tearDownActiveTunnel();
Expand All @@ -233,8 +264,7 @@ private synchronized OutlinePlugin.ErrorCode startTunnel(
final boolean remoteUdpForwardingEnabled =
isAutoStart ? tunnelStore.isUdpSupported() : errorCode == OutlinePlugin.ErrorCode.NO_ERROR;
try {
vpnTunnel.connectTunnel(proxyConfig.host, proxyConfig.port, proxyConfig.password,
proxyConfig.method, remoteUdpForwardingEnabled);
vpnTunnel.connectTunnel(client, remoteUdpForwardingEnabled);
} catch (Exception e) {
LOG.log(Level.SEVERE, "Failed to connect the tunnel", e);
tearDownActiveTunnel();
Expand Down Expand Up @@ -286,10 +316,9 @@ private void stopVpnTunnel() {

// Shadowsocks

private OutlinePlugin.ErrorCode checkServerConnectivity(final ShadowsocksConfig config) {
private OutlinePlugin.ErrorCode checkServerConnectivity(final shadowsocks.Client client) {
try {
long errorCode = shadowsocks.Shadowsocks.checkConnectivity(
config.host, config.port, config.password, config.method);
long errorCode = Shadowsocks.checkConnectivity(client);
OutlinePlugin.ErrorCode result = OutlinePlugin.ErrorCode.values()[(int) errorCode];
LOG.info(String.format(Locale.ROOT, "Go connectivity check result: %s", result.name()));
return result;
Expand Down Expand Up @@ -429,6 +458,16 @@ private void storeActiveTunnel(final TunnelConfig config, boolean isUdpSupported
proxyConfig.put("port", config.proxy.port);
proxyConfig.put("password", config.proxy.password);
proxyConfig.put("method", config.proxy.method);

if (config.proxy.prefix != null) {
char[] chars = new char[config.proxy.prefix.length];
for (int i = 0; i < config.proxy.prefix.length; i++) {
// Unsigned bit width extension requires a mask in Java.
chars[i] = (char)(config.proxy.prefix[i] & 0xFF);
}
proxyConfig.put("prefix", new String(chars));
}

tunnel.put(TUNNEL_ID_KEY, config.id).put(TUNNEL_CONFIG_KEY, proxyConfig);
tunnelStore.save(tunnel);
} catch (JSONException e) {
Expand Down
9 changes: 8 additions & 1 deletion cordova-plugin-outline/apple/src/OutlineTunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ class OutlineTunnel: NSObject, Codable {
var port: String?
var method: String?
var password: String?
var prefix: Data?
var config: [String: String] {
let scalars = prefix?.map{Unicode.Scalar($0)}
let characters = scalars?.map{Character($0)}
let prefixStr = String(characters ?? [])
return ["host": host ?? "", "port": port ?? "", "password": password ?? "",
"method": method ?? ""]
"method": method ?? "", "prefix": prefixStr]
}

@objc
Expand All @@ -44,6 +48,9 @@ class OutlineTunnel: NSObject, Codable {
if let port = config["port"] {
self.port = String(describing: port) // Handle numeric values
}
if let prefix = config["prefix"] as? String {
self.prefix = Data(prefix.utf16.map{UInt8($0)})
}
}

func encode() -> Data? {
Expand Down
48 changes: 38 additions & 10 deletions cordova-plugin-outline/apple/vpn/PacketTunnelProvider.m
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,13 @@ - (void)startTunnelWithOptions:(NSDictionary *)options
// culprit and can explicitly disconnect.
long errorCode = noError;
if (!isOnDemand) {
ShadowsocksCheckConnectivity(self.hostNetworkAddress, [self.tunnelConfig.port intValue],
self.tunnelConfig.password, self.tunnelConfig.method, &errorCode,
nil);
ShadowsocksClient* client = [self getClient];
if (client == nil) {
return completionHandler([NSError errorWithDomain:NEVPNErrorDomain
code:NEVPNErrorConfigurationInvalid
userInfo:nil]);
}
ShadowsocksCheckConnectivity(client, &errorCode, nil);
}
if (errorCode != noError && errorCode != udpRelayNotEnabled) {
[self execAppCallbackForAction:kActionStart errorCode:errorCode];
Expand Down Expand Up @@ -264,6 +268,21 @@ - (OutlineTunnel *)retrieveTunnelConfig:(NSDictionary *)config {

# pragma mark - Network

- (ShadowsocksClient*) getClient {
ShadowsocksConfig* config = [ShadowsocksConfig init];
config.host = self.hostNetworkAddress;
config.port = [self.tunnelConfig.port intValue];
config.password = self.tunnelConfig.password;
config.cipherName = self.tunnelConfig.method;
config.prefix = self.tunnelConfig.prefix;
NSError *err;
ShadowsocksClient* client = ShadowsocksNewClient(config, &err);
if (err != nil) {
DDLogInfo(@"Failed to construct client.");
}
return client;
}

- (void)connectTunnel:(NEPacketTunnelNetworkSettings *)settings
completion:(void (^)(NSError *))completionHandler {
__weak PacketTunnelProvider *weakSelf = self;
Expand Down Expand Up @@ -492,10 +511,16 @@ - (void)reconnectTunnel:(bool)configChanged {

DDLogInfo(@"Configuration or host IP address changed with the network. Reconnecting tunnel.");
self.hostNetworkAddress = activeHostNetworkAddress;
ShadowsocksClient* client = [self getClient];
if (client == nil) {
[self execAppCallbackForAction:kActionStart errorCode:illegalServerConfiguration];
[self cancelTunnelWithError:[NSError errorWithDomain:NEVPNErrorDomain
code:NEVPNErrorConfigurationInvalid
userInfo:nil]];
return;
}
long errorCode = noError;
ShadowsocksCheckConnectivity(self.hostNetworkAddress, [self.tunnelConfig.port intValue],
self.tunnelConfig.password, self.tunnelConfig.method, &errorCode,
nil);
ShadowsocksCheckConnectivity(client, &errorCode, nil);
if (errorCode != noError && errorCode != udpRelayNotEnabled) {
DDLogError(@"Connectivity checks failed. Tearing down VPN");
[self execAppCallbackForAction:kActionStart errorCode:errorCode];
Expand Down Expand Up @@ -556,10 +581,13 @@ - (BOOL)startTun2Socks:(BOOL)isUdpSupported {
[self.tunnel disconnect];
}
__weak PacketTunnelProvider *weakSelf = self;
NSError *err;
ShadowsocksClient* client = [self getClient];
if (client == nil) {
return NO;
}
NSError* err;
self.tunnel = Tun2socksConnectShadowsocksTunnel(
weakSelf, self.hostNetworkAddress, [self.tunnelConfig.port intValue],
self.tunnelConfig.password, self.tunnelConfig.method, isUdpSupported, &err);
weakSelf, client, isUdpSupported, &err);
if (err != nil) {
DDLogError(@"Failed to start tun2socks: %@", err);
return NO;
Expand Down Expand Up @@ -590,4 +618,4 @@ - (void)execAppCallbackForAction:(NSString *)action errorCode:(ErrorCode)code {
}
}

@end
@end
5 changes: 4 additions & 1 deletion src/electron/go_vpn_tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class GoVpnTunnel implements VpnTunnel {
// 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();
}

Expand Down Expand Up @@ -228,7 +229,8 @@ class GoTun2socks {
// -tunName outline-tap0 -tunDNS 1.1.1.1,9.9.9.9 \
// -tunAddr 10.0.85.2 -tunGw 10.0.85.1 -tunMask 255.255.255.0 \
// -proxyHost 127.0.0.1 -proxyPort 1080 -proxyPassword mypassword \
// -proxyCipher chacha20-ietf-poly1035 [-dnsFallback] [-checkConnectivity]
// -proxyCipher chacha20-ietf-poly1035
// [-dnsFallback] [-checkConnectivity] [-proxyPrefix]
const args: string[] = [];
args.push('-tunName', TUN2SOCKS_TAP_DEVICE_NAME);
args.push('-tunAddr', TUN2SOCKS_TAP_DEVICE_IP);
Expand All @@ -239,6 +241,7 @@ class GoTun2socks {
args.push('-proxyPort', `${this.config.port}`);
args.push('-proxyPassword', this.config.password || '');
args.push('-proxyCipher', this.config.method || '');
args.push('-proxyPrefix', this.config.prefix || '');
args.push('-logLevel', this.process.isDebugModeEnabled ? 'debug' : 'info');
if (!isUdpEnabled) {
args.push('-dnsFallback');
Expand Down
3 changes: 2 additions & 1 deletion src/electron/routing_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ export class RoutingDaemon {
);
}));

const initialErrorHandler = () => {
const initialErrorHandler = (err: Error) => {
console.error('Routing daemon socket setup failed', err);
this.socket = null;
reject(new SystemConfigurationException('routing daemon is not running'));
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function staticKeyToShadowsocksSessionConfig(staticKey: string): Shadowso
port: config.port.data,
method: config.method.data,
password: config.password.data,
prefix: config.extra['prefix'],
};
} catch (error) {
throw new errors.ServerAccessKeyInvalid(error.message || 'Failed to parse static access key.');
Expand All @@ -38,19 +39,21 @@ export function staticKeyToShadowsocksSessionConfig(staticKey: string): Shadowso
function parseShadowsocksSessionConfigJson(maybeJsonText: string): ShadowsocksSessionConfig | null {
let sessionConfig;
try {
const {method, password, server: host, server_port: port} = JSON.parse(maybeJsonText);
const {method, password, server: host, server_port: port, extra} = JSON.parse(maybeJsonText);

sessionConfig = {
method,
password,
host,
port,
prefix: extra['prefix'],
};
} catch (_) {
// It's not JSON, so return null.
return null;
}

// These are the mandatory keys.
for (const key of ['method', 'password', 'host', 'port']) {
if (sessionConfig && !sessionConfig[key]) {
throw new errors.ServerAccessKeyInvalid(
Expand Down
8 changes: 7 additions & 1 deletion src/www/app/outline_server_repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ function staticKeysMatch(a: string, b: string): boolean {
try {
const l = staticKeyToShadowsocksSessionConfig(a);
const r = staticKeyToShadowsocksSessionConfig(b);
return l.host === r.host && l.port === r.port && l.password === r.password && l.method === r.method;
return (
l.host === r.host &&
l.port === r.port &&
l.password === r.password &&
l.method === r.method &&
l.prefix == r.prefix
);
} catch (e) {
console.debug(`failed to parse access key for comparison`);
}
Expand Down
1 change: 1 addition & 0 deletions src/www/app/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ShadowsocksSessionConfig {
port?: number;
password?: string;
method?: string;
prefix?: string;
}

export const enum TunnelStatus {
Expand Down

0 comments on commit bd1fc96

Please sign in to comment.