Skip to content

Commit

Permalink
feat(client/linux): revamp the Linux VPN routing logic (#2291)
Browse files Browse the repository at this point in the history
This PR simplifies the Linux VPN routing architecture by:

- **Removing the daemon service:** All routing is now handled by the app process and its backend library.
- **Leveraging NetworkManager for routing and DNS:** Provides more robust DNS configuration and avoids conflicts with other resolver services; and prevents pollution of the user's default routing table.
  • Loading branch information
jyyi1 authored Jan 6, 2025
1 parent c7e829c commit 14dd7cf
Show file tree
Hide file tree
Showing 23 changed files with 1,091 additions and 41 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/license.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ jobs:

- name: Check Go dependency tree licenses
# We allow only "notice" type of licenses.
run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
14 changes: 14 additions & 0 deletions client/electron/debian/after_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,24 @@

# Dependencies:
# - libcap2-bin: setcap
# - patchelf: patchelf

set -eux

# Capabilitites will disable LD_LIBRARY_PATH, and $ORIGIN evaluation in binary's
# rpath. So we need to set the rpath to an absolute path. (for libffmpeg.so)
# This command will also reset capabilitites, so we need to run this before setcap.
/usr/bin/patchelf --add-rpath /opt/Outline /opt/Outline/Outline

# Grant specific capabilities so Outline can run without root permisssion
# - cap_net_admin: configure network interfaces, set up routing tables, etc.
# - cap_dac_override: modify network configuration files owned by root
/usr/sbin/setcap cap_net_admin,cap_dac_override+eip /opt/Outline/Outline

# From electron's hint:
# > The SUID sandbox helper binary was found, but is not configured correctly.
# > Rather than run without sandboxing I'm aborting now. You need to make sure
# > that /opt/Outline/chrome-sandbox is owned by root and has mode 4755.
#
# https://github.com/electron/electron/issues/42510
/usr/bin/chmod 4755 /opt/Outline/chrome-sandbox
4 changes: 2 additions & 2 deletions client/electron/electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

"deb": {
"depends": [
"gconf2", "gconf-service", "libnotify4", "libappindicator1", "libxtst6", "libnss3",
"libcap2-bin"
"libnotify4", "libxtst6", "libnss3",
"libcap2-bin", "patchelf"
],
"afterInstall": "client/electron/debian/after_install.sh"
},
Expand Down
4 changes: 2 additions & 2 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ export async function invokeMethod(
);
}

console.debug('[Backend] - calling InvokeMethod ...');
console.debug(`[Backend] - calling InvokeMethod "${method}" ...`);
const result = await invokeMethodFunc(method, input);
console.debug('[Backend] - InvokeMethod returned', result);
console.debug(`[Backend] - InvokeMethod "${method}" returned`, result);
if (result.ErrorJson) {
throw Error(result.ErrorJson);
}
Expand Down
25 changes: 20 additions & 5 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {invokeMethod} from './go_plugin';
import {GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
import {TunnelStore} from './tunnel_store';
import {closeVpn, establishVpn, onVpnStatusChanged} from './vpn_service';
import {VpnTunnel} from './vpn_tunnel';
import * as config from '../src/www/app/outline_server_repository/config';
import {
Expand All @@ -56,7 +57,7 @@ declare const APP_VERSION: string;
// Run-time environment variables:
const debugMode = process.env.OUTLINE_DEBUG === 'true';

const isLinux = os.platform() === 'linux';
const IS_LINUX = os.platform() === 'linux';

// Used for the auto-connect feature. There will be a tunnel in store
// if the user was connected at shutdown.
Expand Down Expand Up @@ -158,7 +159,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) {
if (IS_LINUX) {
mainWindow.setIcon(
path.join(
app.getAppPath(),
Expand Down Expand Up @@ -251,7 +252,7 @@ function updateTray(status: TunnelStatus) {
{type: 'separator'} as MenuItemConstructorOptions,
{label: localizedStrings['quit'], click: quitApp},
];
if (isLinux) {
if (IS_LINUX) {
// Because the click event is never fired on Linux, we need an explicit open option.
menuTemplate = [
{
Expand Down Expand Up @@ -309,7 +310,7 @@ function interceptShadowsocksLink(argv: string[]) {
async function setupAutoLaunch(request: StartRequestJson): Promise<void> {
try {
await tunnelStore.save(request);
if (isLinux) {
if (IS_LINUX) {
if (process.env.APPIMAGE) {
const outlineAutoLauncher = new autoLaunch({
name: 'OutlineClient',
Expand All @@ -327,7 +328,7 @@ async function setupAutoLaunch(request: StartRequestJson): Promise<void> {

async function tearDownAutoLaunch() {
try {
if (isLinux) {
if (IS_LINUX) {
const outlineAutoLauncher = new autoLaunch({
name: 'OutlineClient',
});
Expand Down Expand Up @@ -368,6 +369,15 @@ async function createVpnTunnel(

// Invoked by both the start-proxying event handler and auto-connect.
async function startVpn(request: StartRequestJson, isAutoConnect: boolean) {
if (IS_LINUX && !process.env.APPIMAGE) {
onVpnStatusChanged((id, status) => {
setUiTunnelStatus(status, id);
console.info('VPN Status Changed: ', id, status);
});
await establishVpn(request);
return;
}

if (currentTunnel) {
throw new Error('already connected');
}
Expand Down Expand Up @@ -401,6 +411,11 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) {

// Invoked by both the stop-proxying event and quit handler.
async function stopVpn() {
if (IS_LINUX && !process.env.APPIMAGE) {
await Promise.all([closeVpn(), tearDownAutoLaunch()]);
return;
}

if (!currentTunnel) {
return;
}
Expand Down
90 changes: 90 additions & 0 deletions client/electron/vpn_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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 {invokeMethod} from './go_plugin';
import {
StartRequestJson,
TunnelStatus,
} from '../src/www/app/outline_server_repository/vpn';

// TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share.
interface VpnConfig {
id: string;
interfaceName: string;
connectionName: string;
ipAddress: string;
dnsServers: string[];
routingTableId: number;
routingPriority: number;
protectionMark: number;
}

interface EstablishVpnRequest {
vpn: VpnConfig;
transport: string;
}

let currentRequestId: string | undefined = undefined;

export async function establishVpn(request: StartRequestJson) {
currentRequestId = request.id;
statusCb?.(currentRequestId, TunnelStatus.RECONNECTING);

const config: EstablishVpnRequest = {
vpn: {
id: currentRequestId,

// TUN device name, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203
interfaceName: 'outline-tun0',

// Network Manager connection name, Use "TUN Connection" instead of "VPN Connection"
// because Network Manager has a dedicated "VPN Connection" concept that we did not implement
connectionName: 'Outline TUN Connection',

// TUN IP, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204
ipAddress: '10.0.85.1',

// DNS server list, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207
dnsServers: ['9.9.9.9'],

// Outline magic numbers, 7113 and 0x711E visually resembles "T L I E" in "ouTLInE"
routingTableId: 7113,
routingPriority: 0x711e,
protectionMark: 0x711e,
},

// The actual transport config
transport: JSON.stringify(request.config.transport),
};

await invokeMethod('EstablishVPN', JSON.stringify(config));
statusCb?.(currentRequestId, TunnelStatus.CONNECTED);
}

export async function closeVpn(): Promise<void> {
statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTING);
await invokeMethod('CloseVPN', '');
statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTED);
}

export type VpnStatusCallback = (id: string, status: TunnelStatus) => void;

let statusCb: VpnStatusCallback | undefined = undefined;

export function onVpnStatusChanged(cb: VpnStatusCallback): void {
statusCb = cb;
}
30 changes: 18 additions & 12 deletions client/go/outline/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,29 @@ type NewClientResult struct {

// NewClient creates a new Outline client from a configuration string.
func NewClient(transportConfig string) *NewClientResult {
config, err := parseConfigFromJSON(transportConfig)
client, err := newClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{})
return &NewClientResult{
Client: client,
Error: platerrors.ToPlatformError(err),
}
}

func newClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) {
conf, err := parseConfigFromJSON(transportConfig)
if err != nil {
return &NewClientResult{Error: platerrors.ToPlatformError(err)}
return nil, err
}
prefixBytes, err := ParseConfigPrefixFromString(config.Prefix)
prefixBytes, err := ParseConfigPrefixFromString(conf.Prefix)
if err != nil {
return &NewClientResult{Error: platerrors.ToPlatformError(err)}
return nil, err
}

client, err := newShadowsocksClient(config.Host, int(config.Port), config.Method, config.Password, prefixBytes)
return &NewClientResult{
Client: client,
Error: platerrors.ToPlatformError(err),
}
return newShadowsocksClient(conf.Host, int(conf.Port), conf.Method, conf.Password, prefixBytes, tcpDialer, udpDialer)
}

func newShadowsocksClient(host string, port int, cipherName, password string, prefix []byte) (*Client, error) {
func newShadowsocksClient(
host string, port int, cipherName, password string, prefix []byte, tcpDialer, udpDialer net.Dialer,
) (*Client, error) {
if err := validateConfig(host, port, cipherName, password); err != nil {
return nil, err
}
Expand All @@ -74,7 +80,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr

// We disable Keep-Alive as per https://datatracker.ietf.org/doc/html/rfc1122#page-101, which states that it should only be
// enabled in server applications. This prevents the device from unnecessarily waking up to send keep alives.
streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: net.Dialer{KeepAlive: -1}}, cryptoKey)
streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: tcpDialer}, cryptoKey)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Expand All @@ -88,7 +94,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr
streamDialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(prefix)
}

packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress}, cryptoKey)
packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress, Dialer: udpDialer}, cryptoKey)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Expand Down
19 changes: 1 addition & 18 deletions client/go/outline/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,10 @@
package outline

import (
"net"

"github.com/Jigsaw-Code/outline-apps/client/go/outline/connectivity"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
)

const (
tcpTestWebsite = "http://example.com"
dnsServerIP = "1.1.1.1"
dnsServerPort = 53
)

// TCPAndUDPConnectivityResult represents the result of TCP and UDP connectivity checks.
//
// We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes.
Expand All @@ -40,16 +32,7 @@ type TCPAndUDPConnectivityResult struct {
// containing a TCP error and a UDP error.
// If the connectivity check was successful, the corresponding error field will be nil.
func CheckTCPAndUDPConnectivity(client *Client) *TCPAndUDPConnectivityResult {
// Start asynchronous UDP support check.
udpErrChan := make(chan error)
go func() {
resolverAddr := &net.UDPAddr{IP: net.ParseIP(dnsServerIP), Port: dnsServerPort}
udpErrChan <- connectivity.CheckUDPConnectivityWithDNS(client, resolverAddr)
}()

tcpErr := connectivity.CheckTCPConnectivityWithHTTP(client, tcpTestWebsite)
udpErr := <-udpErrChan

tcpErr, udpErr := connectivity.CheckTCPAndUDPConnectivity(client, client)
return &TCPAndUDPConnectivityResult{
TCPError: platerrors.ToPlatformError(tcpErr),
UDPError: platerrors.ToPlatformError(udpErr),
Expand Down
25 changes: 25 additions & 0 deletions client/go/outline/connectivity/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ const (
bufferLength = 512
)

const (
testTCPWebsite = "http://example.com"
testDNSServerIP = "1.1.1.1"
testDNSServerPort = 53
)

// CheckTCPAndUDPConnectivity checks whether the given `tcp` and `udp` clients can relay traffic.
//
// It parallelizes the execution of TCP and UDP checks, and returns a TCP error and a UDP error.
// A nil error indicates successful connectivity for the corresponding protocol.
func CheckTCPAndUDPConnectivity(
tcp transport.StreamDialer, udp transport.PacketListener,
) (tcpErr error, udpErr error) {
// Start asynchronous UDP support check.
udpErrChan := make(chan error)
go func() {
resolverAddr := &net.UDPAddr{IP: net.ParseIP(testDNSServerIP), Port: testDNSServerPort}
udpErrChan <- CheckUDPConnectivityWithDNS(udp, resolverAddr)
}()

tcpErr = CheckTCPConnectivityWithHTTP(tcp, testTCPWebsite)
udpErr = <-udpErrChan
return
}

// CheckUDPConnectivityWithDNS determines whether the Outline proxy represented by `client` and
// the network support UDP traffic by issuing a DNS query though a resolver at `resolverAddr`.
// Returns nil on success or an error on failure.
Expand Down
45 changes: 45 additions & 0 deletions client/go/outline/dialer_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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.

package outline

import (
"net"
"syscall"
)

// newFWMarkProtectedTCPDialer creates a base TCP dialer for [Client]
// protected by the specified firewall mark.
func newFWMarkProtectedTCPDialer(fwmark uint32) net.Dialer {
return net.Dialer{
KeepAlive: -1,
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark))
})
},
}
}

// newFWMarkProtectedUDPDialer creates a new UDP dialer for [Client]
// protected by the specified firewall mark.
func newFWMarkProtectedUDPDialer(fwmark uint32) net.Dialer {
return net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(fwmark))
})
},
}
}
Loading

0 comments on commit 14dd7cf

Please sign in to comment.