From a6606f8c32876ae89c8397e9b80c4bdd31191331 Mon Sep 17 00:00:00 2001 From: jyyi1 Date: Mon, 16 Dec 2024 15:55:47 -0500 Subject: [PATCH] refactor vpn package architecture --- client/electron/vpn_service.ts | 52 ++--- client/go/outline/device.go | 47 ++--- client/go/outline/electron/go_plugin.go | 32 ++-- client/go/outline/method_channel.go | 33 ++-- client/go/outline/vpn.go | 82 ++++++++ client/go/outline/vpn/dialer.go | 19 ++ .../{client_linux.go => vpn/dialer_linux.go} | 31 ++- .../{method_channel.go => dialer_others.go} | 15 +- client/go/outline/vpn/errors.go | 7 +- client/go/outline/vpn/vpn.go | 178 ++++++++++++------ client/go/outline/vpn/vpn_linux.go | 86 +-------- client/go/outline/vpn/vpn_others.go | 2 +- 12 files changed, 337 insertions(+), 247 deletions(-) create mode 100644 client/go/outline/vpn.go create mode 100644 client/go/outline/vpn/dialer.go rename client/go/outline/{client_linux.go => vpn/dialer_linux.go} (59%) rename client/go/outline/vpn/{method_channel.go => dialer_others.go} (68%) diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 6c8f65c8f23..a43068a5d7d 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -19,7 +19,7 @@ import { } from '../src/www/app/outline_server_repository/vpn'; // TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share. -interface EstablishVpnRequest { +interface VpnConfig { id: string; interfaceName: string; connectionName: string; @@ -28,6 +28,10 @@ interface EstablishVpnRequest { routingTableId: number; routingPriority: number; protectionMark: number; +} + +interface EstablishVpnRequest { + vpn: VpnConfig; transport: string; } @@ -38,28 +42,30 @@ export async function establishVpn(request: StartRequestJson) { statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); const config: EstablishVpnRequest = { - 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, + 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), diff --git a/client/go/outline/device.go b/client/go/outline/device.go index 29e03d16d4c..427ed3e063a 100644 --- a/client/go/outline/device.go +++ b/client/go/outline/device.go @@ -15,9 +15,12 @@ package outline import ( + "context" + "errors" "log/slog" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" "github.com/Jigsaw-Code/outline-sdk/network" "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" @@ -28,26 +31,29 @@ type Device struct { c *Client pkt network.DelegatePacketProxy - supportsUDP *bool + supportsUDP bool remote, fallback network.PacketProxy } -type LinuxOptions struct { - FWMark uint32 +var _ vpn.ProxyDevice = (*Device)(nil) + +func NewDevice(c *Client) (*Device, error) { + if c == nil { + return nil, errors.New("Client must be provided") + } + return &Device{c: c}, nil } -type DeviceOptions struct { - LinuxOpts *LinuxOptions +func (d *Device) SupportsUDP() bool { + return d.supportsUDP } -func NewDevice(c *Client) *Device { - return &Device{ - c: c, +func (d *Device) Connect(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} } -} -func (d *Device) Connect() (err error) { d.remote, err = network.NewPacketProxyFromPacketListener(d.c.PacketListener) if err != nil { return errSetupHandler("failed to create datagram handler", err) @@ -59,7 +65,7 @@ func (d *Device) Connect() (err error) { } slog.Debug("[Outline] local DNS-fallback UDP handler created") - if err = d.RefreshConnectivity(); err != nil { + if err = d.RefreshConnectivity(ctx); err != nil { return } @@ -69,7 +75,6 @@ func (d *Device) Connect() (err error) { } slog.Debug("[Outline] lwIP network stack configured") - slog.Info("[Outline] successfully connected to Outline server") return nil } @@ -77,11 +82,14 @@ func (d *Device) Close() (err error) { if d.IPDevice != nil { err = d.IPDevice.Close() } - slog.Info("[Outline] successfully disconnected from Outline server") return } -func (d *Device) RefreshConnectivity() (err error) { +func (d *Device) RefreshConnectivity(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} + } + slog.Debug("[Outine] Testing connectivity of Outline server ...") result := CheckTCPAndUDPConnectivity(d.c) if result.TCPError != nil { @@ -90,14 +98,14 @@ func (d *Device) RefreshConnectivity() (err error) { } var proxy network.PacketProxy - canHandleUDP := false + d.supportsUDP = false if result.UDPError != nil { slog.Warn("[Outline] server cannot handle UDP traffic", "err", result.UDPError) proxy = d.fallback } else { slog.Debug("[Outline] server can handle UDP traffic") proxy = d.remote - canHandleUDP = true + d.supportsUDP = true } if d.pkt == nil { @@ -109,15 +117,10 @@ func (d *Device) RefreshConnectivity() (err error) { return errSetupHandler("failed to update combined datagram handler", err) } } - d.supportsUDP = &canHandleUDP - slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", canHandleUDP) + slog.Info("[Outline] Outline server connectivity test done", "supportsUDP", d.supportsUDP) return nil } -func (d *Device) SupportsUDP() *bool { - return d.supportsUDP -} - func errSetupHandler(msg string, cause error) error { slog.Error("[Outline] "+msg, "err", cause) return perrs.PlatformError{ diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index 9f2ecd1df53..ea66d07d878 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -39,26 +39,8 @@ import ( "github.com/Jigsaw-Code/outline-apps/client/go/outline" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" ) -// init initializes the backend module. -// It sets up a default logger based on the OUTLINE_DEBUG environment variable. -func init() { - opts := slog.HandlerOptions{Level: slog.LevelInfo} - - dbg := os.Getenv("OUTLINE_DEBUG") - if dbg != "" && dbg != "false" && dbg != "0" { - opts.Level = slog.LevelDebug - } - - logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) - slog.SetDefault(logger) - - // Register VPN handlers for desktop environments - vpn.RegisterMethodHandlers() -} - // InvokeMethod is the unified entry point for TypeScript to invoke various Go functions. // // The input and output are all defined as string, but they may represent either a raw string, @@ -109,3 +91,17 @@ func marshalCGoErrorJson(e *platerrors.PlatformError) *C.char { } return newCGoString(json) } + +// init initializes the backend module. +// It sets up a default logger based on the OUTLINE_DEBUG environment variable. +func init() { + opts := slog.HandlerOptions{Level: slog.LevelInfo} + + dbg := os.Getenv("OUTLINE_DEBUG") + if dbg != "" && dbg != "false" && dbg != "0" { + opts.Level = slog.LevelDebug + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) + slog.SetDefault(logger) +} diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index 61daceaf5c5..dfb92b9a98a 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -41,20 +41,6 @@ const ( MethodCloseVPN = "CloseVPN" ) -// Handler is an interface that defines a method for handling requests from TypeScript. -type Handler func(string) (string, error) - -// handlers is a map of registered handlers. -var handlers = make(map[string]Handler) - -// RegisterMethodHandler registers a native function handler for the given method. -// -// Instead of having [InvokeMethod] directly depend on other packages, we use dependency inversion -// pattern here. This breaks Go's dependency cycle and makes the code more flexible. -func RegisterMethodHandler(method string, handler Handler) { - handlers[method] = handler -} - // InvokeMethodResult represents the result of an InvokeMethod call. // // We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes. @@ -74,15 +60,20 @@ func InvokeMethod(method string, input string) *InvokeMethodResult { Error: platerrors.ToPlatformError(err), } - default: - if h, ok := handlers[method]; ok { - val, err := h(input) - return &InvokeMethodResult{ - Value: val, - Error: platerrors.ToPlatformError(err), - } + case MethodEstablishVPN: + conn, err := establishVPN(input) + return &InvokeMethodResult{ + Value: conn, + Error: platerrors.ToPlatformError(err), + } + + case MethodCloseVPN: + err := closeVPN() + return &InvokeMethodResult{ + Error: platerrors.ToPlatformError(err), } + default: return &InvokeMethodResult{Error: &platerrors.PlatformError{ Code: platerrors.InternalError, Message: fmt.Sprintf("unsupported Go method: %s", method), diff --git a/client/go/outline/vpn.go b/client/go/outline/vpn.go new file mode 100644 index 00000000000..e1f7812d7a3 --- /dev/null +++ b/client/go/outline/vpn.go @@ -0,0 +1,82 @@ +// 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 ( + "encoding/json" + "net" + + perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" +) + +type vpnConfigJSON struct { + VPNConfig vpn.Config `json:"vpn"` + TransportConfig string `json:"transport"` +} + +func establishVPN(configStr string) (string, error) { + var conf vpnConfigJSON + if err := json.Unmarshal([]byte(configStr), &conf); err != nil { + return "", perrs.PlatformError{ + Code: perrs.IllegalConfig, + Message: "invalid VPN config format", + Cause: perrs.ToPlatformError(err), + } + } + + // Create Outline Client and Device + tcpControl, err := vpn.TCPDialerControl(&conf.VPNConfig) + if err != nil { + return "", err + } + tcp := net.Dialer{ + Control: tcpControl, + KeepAlive: -1, + } + udpControl, err := vpn.UDPDialerControl(&conf.VPNConfig) + if err != nil { + return "", err + } + udp := net.Dialer{Control: udpControl} + c, err := newClientWithBaseDialers(conf.TransportConfig, tcp, udp) + if err != nil { + return "", err + } + proxy, err := NewDevice(c) + if err != nil { + return "", err + } + + // Establish system VPN to the proxy + conn, err := vpn.EstablishVPN(&conf.VPNConfig, proxy) + if err != nil { + return "", err + } + + connJson, err := json.Marshal(conn) + if err != nil { + return "", perrs.PlatformError{ + Code: perrs.InternalError, + Message: "failed to return VPN connection as JSON", + Cause: perrs.ToPlatformError(err), + } + } + return string(connJson), nil +} + +func closeVPN() error { + return vpn.CloseVPN() +} diff --git a/client/go/outline/vpn/dialer.go b/client/go/outline/vpn/dialer.go new file mode 100644 index 00000000000..98a7040e62c --- /dev/null +++ b/client/go/outline/vpn/dialer.go @@ -0,0 +1,19 @@ +// 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 vpn + +import "syscall" + +type ControlFn = func(network, address string, c syscall.RawConn) error diff --git a/client/go/outline/client_linux.go b/client/go/outline/vpn/dialer_linux.go similarity index 59% rename from client/go/outline/client_linux.go rename to client/go/outline/vpn/dialer_linux.go index 007bebcf13e..a1ad244ec92 100644 --- a/client/go/outline/client_linux.go +++ b/client/go/outline/vpn/dialer_linux.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Outline Authors +// 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. @@ -12,29 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. -package outline +package vpn import ( - "net" + "errors" "syscall" ) -// NewClient creates a new Outline client from a configuration string. -func NewClientWithFWMark(transportConfig string, fwmark uint32) (*Client, error) { - control := func(network, address string, c syscall.RawConn) error { +func TCPDialerControl(conf *Config) (ControlFn, error) { + if conf == nil { + return nil, errors.New("VPN config must be provided") + } + return 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)) + syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(conf.ProtectionMark)) }) - } - - tcp := net.Dialer{ - Control: control, - KeepAlive: -1, - } - - udp := net.Dialer{ - Control: control, - } + }, nil +} - return newClientWithBaseDialers(transportConfig, tcp, udp) +func UDPDialerControl(conf *Config) (ControlFn, error) { + return TCPDialerControl(conf) } diff --git a/client/go/outline/vpn/method_channel.go b/client/go/outline/vpn/dialer_others.go similarity index 68% rename from client/go/outline/vpn/method_channel.go rename to client/go/outline/vpn/dialer_others.go index 13fb1019397..8dd1fd3ad97 100644 --- a/client/go/outline/vpn/method_channel.go +++ b/client/go/outline/vpn/dialer_others.go @@ -12,13 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !linux + package vpn -import "github.com/Jigsaw-Code/outline-apps/client/go/outline" +import "errors" + +func TCPDialerControl(conf *Config) (ControlFn, error) { + return nil, errors.ErrUnsupported +} -func RegisterMethodHandlers() { - outline.RegisterMethodHandler(outline.MethodEstablishVPN, EstablishVPN) - outline.RegisterMethodHandler(outline.MethodCloseVPN, func(string) (string, error) { - return "", CloseVPN() - }) +func UDPDialerControl(conf *Config) (ControlFn, error) { + return nil, errors.ErrUnsupported } diff --git a/client/go/outline/vpn/errors.go b/client/go/outline/vpn/errors.go index ae6baaa919d..0e4b77ccde8 100644 --- a/client/go/outline/vpn/errors.go +++ b/client/go/outline/vpn/errors.go @@ -21,9 +21,10 @@ import ( ) const ( - ioLogPfx = "[IO] " - nmLogPfx = "[NMDBus] " - vpnLogPfx = "[VPN] " + ioLogPfx = "[IO] " + nmLogPfx = "[NMDBus] " + vpnLogPfx = "[VPN] " + proxyLogPfx = "[Proxy] " ) func errIllegalConfig(msg string, params ...any) error { diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index 1ad90c22d17..9c65990d23f 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -15,16 +15,16 @@ package vpn import ( - "encoding/json" + "context" + "errors" + "io" "log/slog" "sync" - perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" + "github.com/Jigsaw-Code/outline-sdk/network" ) -// configJSON represents the JSON structure for setting up a VPN connection. -// This is typically passed from TypeScript. -type configJSON struct { +type Config struct { ID string `json:"id"` InterfaceName string `json:"interfaceName"` IPAddress string `json:"ipAddress"` @@ -33,15 +33,6 @@ type configJSON struct { RoutingTableId uint32 `json:"routingTableId"` RoutingPriority uint32 `json:"routingPriority"` ProtectionMark uint32 `json:"protectionMark"` - TransportConfig string `json:"transport"` -} - -// connectionJSON defines the JSON structure of a [VPNConnection]. -// This is typically returned to TypeScript. -type connectionJSON struct { - ID string `json:"id"` - Status string `json:"status"` - RouteUDP *bool `json:"supportsUDP"` } // Status defines the possible states of a VPN connection. @@ -56,69 +47,114 @@ const ( StatusDisconnecting Status = "Disconnecting" ) +type ProxyDevice interface { + network.IPDevice + Connect(ctx context.Context) error + SupportsUDP() bool + RefreshConnectivity(ctx context.Context) error +} + +type platformVPNConn interface { + Establish(ctx context.Context) error + TUN() io.ReadWriteCloser + Close() error +} + // VPNConnection is a platform neutral interface of a VPN connection. -type VPNConnection interface { - // ID returns the unique identifier of this VPNConnection. - // Typically it is passed in from the TypeScript through configJson. - ID() string +type VPNConnection struct { + ID string `json:"id"` + Status Status `json:"status"` + SupportsUDP *bool `json:"supportsUDP"` - // Status returns the current Status of the VPNConnection. - Status() Status + ctx context.Context + cancel context.CancelFunc + wgEst, wgCopy sync.WaitGroup - // SupportsUDP indicates whether the remote proxy can handle UDP traffic. - // nil means unknown. - SupportsUDP() *bool + proxy ProxyDevice + platform platformVPNConn +} - // Establish tries to connect this VPNConnection. - Establish() error +func (c *VPNConnection) SetStatus(s Status) { + c.Status = s +} - // Close tries to disconnect this VPNConnection. - Close() error +func (c *VPNConnection) SetSupportsUDP(v bool) { + c.SupportsUDP = &v } // The global singleton VPN connection. // This package allows at most one active VPN connection at the same time. var mu sync.Mutex -var conn VPNConnection +var conn *VPNConnection // EstablishVPN establishes a new active [VPNConnection] with the given configuration. // It will first close any active [VPNConnection] using [CloseVPN], and then mark the // newly created [VPNConnection] as the currently active connection. // It returns the connectionJSON as a string, or an error if the connection fails. -func EstablishVPN(configStr string) (_ string, err error) { - var conf configJSON - if err = json.Unmarshal([]byte(configStr), &conf); err != nil { - return "", perrs.PlatformError{ - Code: perrs.IllegalConfig, - Message: "invalid VPN config format", - Cause: perrs.ToPlatformError(err), - } +func EstablishVPN(conf *Config, proxy ProxyDevice) (_ *VPNConnection, err error) { + if conf == nil { + return nil, errors.New("a VPN Config must be provided") + } + if proxy == nil { + return nil, errors.New("a proxy device must be provided") } - var c VPNConnection - if c, err = newVPNConnection(&conf); err != nil { + c := &VPNConnection{ + ID: conf.ID, + Status: StatusDisconnected, + } + c.ctx, c.cancel = context.WithCancel(context.Background()) + if c.platform, err = newPlatformVPNConn(conf); err != nil { return } + + c.wgEst.Add(1) + defer c.wgEst.Done() + if err = atomicReplaceVPNConn(c); err != nil { - c.Close() + c.platform.Close() return } - slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID()) - if err = c.Establish(); err != nil { - // No need to call c.Close() cuz it's tracked in the global conn already + + slog.Debug(vpnLogPfx+"Establishing VPN connection ...", "id", c.ID) + + c.SetStatus(StatusConnecting) + defer func() { + if err == nil { + c.SetStatus(StatusConnected) + } else { + c.SetStatus(StatusUnknown) + } + }() + + if err = c.proxy.Connect(c.ctx); err != nil { + slog.Error(proxyLogPfx+"Failed to connect to the proxy", "err", err) return } - slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID()) - - connJson, err := json.Marshal(connectionJSON{c.ID(), string(c.Status()), c.SupportsUDP()}) - if err != nil { - return "", perrs.PlatformError{ - Code: perrs.InternalError, - Message: "failed to return VPN connection as JSON", - Cause: perrs.ToPlatformError(err), - } + slog.Info(proxyLogPfx + "Connected to the proxy") + c.SetSupportsUDP(c.proxy.SupportsUDP()) + + if err = c.platform.Establish(c.ctx); err != nil { + // No need to call c.platform.Close() cuz it's already tracked in the global conn + return } - return string(connJson), nil + + c.wgCopy.Add(2) + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") + n, err := io.Copy(c.proxy, c.platform.TUN()) + slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) + }() + go func() { + defer c.wgCopy.Done() + slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") + n, err := io.Copy(c.platform.TUN(), c.proxy) + slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) + }() + + slog.Info(vpnLogPfx+"VPN connection established", "id", c.ID) + return c, nil } // CloseVPN closes the currently active [VPNConnection]. @@ -129,15 +165,15 @@ func CloseVPN() error { } // atomicReplaceVPNConn atomically replaces the global conn with newConn. -func atomicReplaceVPNConn(newConn VPNConnection) error { +func atomicReplaceVPNConn(newConn *VPNConnection) error { mu.Lock() defer mu.Unlock() - slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID()) + slog.Debug(vpnLogPfx+"Creating VPN Connection ...", "id", newConn.ID) if err := closeVPNNoLock(); err != nil { return err } conn = newConn - slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID()) + slog.Info(vpnLogPfx+"VPN Connection created", "id", newConn.ID) return nil } @@ -147,10 +183,36 @@ func closeVPNNoLock() (err error) { if conn == nil { return nil } - slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID()) - if err = conn.Close(); err == nil { - slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID()) + + conn.SetStatus(StatusDisconnecting) + defer func() { + if err == nil { + conn.SetStatus(StatusDisconnected) + } else { + conn.SetStatus(StatusUnknown) + } + }() + + slog.Debug(vpnLogPfx+"Closing existing VPN Connection ...", "id", conn.ID) + + // Cancel the Establish process and wait + conn.cancel() + conn.wgEst.Wait() + + if err = conn.platform.Close(); err == nil { + slog.Info(vpnLogPfx+"VPN Connection closed", "id", conn.ID) conn = nil } + + // We can ignore the following error + if err2 := conn.proxy.Close(); err2 != nil { + slog.Warn(proxyLogPfx + "Failed to disconnect from the proxy") + } else { + slog.Info(proxyLogPfx + "Disconnected from the proxy") + } + + // Wait for traffic copy go routines to finish + conn.wgCopy.Wait() + return } diff --git a/client/go/outline/vpn/vpn_linux.go b/client/go/outline/vpn/vpn_linux.go index f67d00e4e1b..28953cab394 100644 --- a/client/go/outline/vpn/vpn_linux.go +++ b/client/go/outline/vpn/vpn_linux.go @@ -19,39 +19,26 @@ import ( "io" "log/slog" "net" - "sync" - "github.com/Jigsaw-Code/outline-apps/client/go/outline" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" gonm "github.com/Wifx/gonetworkmanager/v2" ) // linuxVPNConn implements a [VPNConnection] on Linux platform. type linuxVPNConn struct { - id string - status Status - - ctx context.Context - cancel context.CancelFunc - wgEst, wgCopy sync.WaitGroup - - tun io.ReadWriteCloser - proxy *outline.Device - + tun io.ReadWriteCloser nmOpts *nmConnectionOptions nm gonm.NetworkManager ac gonm.ActiveConnection } -var _ VPNConnection = (*linuxVPNConn)(nil) +var _ platformVPNConn = (*linuxVPNConn)(nil) // newVPNConnection creates a new Linux specific [VPNConnection]. // The newly connection will be [StatusDisconnected] initially, you need to call the // Establish() in order to make it [StatusConnected]. -func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { +func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { c := &linuxVPNConn{ - id: conf.ID, - status: StatusDisconnected, nmOpts: &nmConnectionOptions{ Name: conf.ConnectionName, TUNName: conf.InterfaceName, @@ -79,66 +66,26 @@ func newVPNConnection(conf *configJSON) (_ *linuxVPNConn, err error) { } c.nmOpts.DNSServers4 = append(c.nmOpts.DNSServers4, dnsIP) } - if conf.TransportConfig == "" { - return nil, errIllegalConfig("must provide a transport config") - } - oc, err := outline.NewClientWithFWMark(conf.TransportConfig, c.nmOpts.FWMark) - if err != nil { - return nil, err - } - c.proxy = outline.NewDevice(oc) - - c.ctx, c.cancel = context.WithCancel(context.Background()) return c, nil } -func (c *linuxVPNConn) ID() string { return c.id } -func (c *linuxVPNConn) Status() Status { return c.status } -func (c *linuxVPNConn) SupportsUDP() *bool { return c.proxy.SupportsUDP() } +func (c *linuxVPNConn) TUN() io.ReadWriteCloser { return c.tun } // Establish tries to establish this [VPNConnection], and makes it [StatusConnected]. -func (c *linuxVPNConn) Establish() (err error) { - c.wgEst.Add(1) - defer c.wgEst.Done() - if c.ctx.Err() != nil { - return &perrs.PlatformError{Code: perrs.OperationCanceled} +func (c *linuxVPNConn) Establish(ctx context.Context) (err error) { + if ctx.Err() != nil { + return perrs.PlatformError{Code: perrs.OperationCanceled} } - c.status = StatusConnecting - defer func() { - if err == nil { - c.status = StatusConnected - } else { - c.status = StatusUnknown - } - }() - - if err = c.proxy.Connect(); err != nil { - return - } - if c.tun, err = newTUNDevice(c.nmOpts.TUNName); err != nil { + if c.tun, err = newTUNDevice(c.nmOpts.Name); err != nil { return errSetupVPN(ioLogPfx, "failed to create TUN device", err, "name", c.nmOpts.Name) } slog.Info(vpnLogPfx+"TUN device created", "name", c.nmOpts.TUNName) + if err = c.establishNMConnection(); err != nil { return } - - c.wgCopy.Add(2) - go func() { - defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from TUN Device -> OutlineDevice...") - n, err := io.Copy(c.proxy, c.tun) - slog.Debug(ioLogPfx+"TUN Device -> OutlineDevice done", "n", n, "err", err) - }() - go func() { - defer c.wgCopy.Done() - slog.Debug(ioLogPfx + "Copying traffic from OutlineDevice -> TUN Device...") - n, err := io.Copy(c.tun, c.proxy) - slog.Debug(ioLogPfx+"OutlineDevice -> TUN Device done", "n", n, "err", err) - }() - return nil } @@ -148,18 +95,6 @@ func (c *linuxVPNConn) Close() (err error) { return nil } - c.status = StatusDisconnecting - defer func() { - if err == nil { - c.status = StatusDisconnected - } else { - c.status = StatusUnknown - } - }() - - c.cancel() - c.wgEst.Wait() - c.closeNMConnection() if c.tun != nil { // this is the only error that matters @@ -169,9 +104,6 @@ func (c *linuxVPNConn) Close() (err error) { slog.Info(vpnLogPfx+"closed TUN device", "name", c.nmOpts.TUNName) } } - c.proxy.Close() - // Wait for traffic copy go routines to finish - c.wgCopy.Wait() return } diff --git a/client/go/outline/vpn/vpn_others.go b/client/go/outline/vpn/vpn_others.go index 016f4061981..30086fd2a9a 100644 --- a/client/go/outline/vpn/vpn_others.go +++ b/client/go/outline/vpn/vpn_others.go @@ -18,6 +18,6 @@ package vpn import "errors" -func newVPNConnection(conf *configJSON) (VPNConnection, error) { +func newPlatformVPNConn(conf *Config) (_ platformVPNConn, err error) { return nil, errors.ErrUnsupported }