Files
olm/olm/connect.go
T
2026-10-01 12:27:24 -04:00

427 lines
14 KiB
Go

package olm
import (
"encoding/json"
"fmt"
"net/netip"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
"github.com/fosrl/newt/util"
olmDevice "github.com/fosrl/olm/device"
"github.com/fosrl/olm/dns"
"github.com/fosrl/olm/peers"
"github.com/fosrl/olm/subnetrouter"
"github.com/fosrl/olm/websocket"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun"
)
// OlmErrorData represents the error data sent from the server
type OlmErrorData struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (o *Olm) handleConnect(msg websocket.WSMessage) {
logger.Debug("Received message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring connect message")
return
}
var wgData WgData
if o.registered {
logger.Info("Already connected. Ignoring new connection request.")
return
}
if o.stopRegister != nil {
o.stopRegister()
o.stopRegister = nil
}
if o.updateRegister != nil {
o.updateRegister = nil
}
if o.stopPingRequest != nil {
o.stopPingRequest()
o.stopPingRequest = nil
}
// if there is an existing tunnel then close it
if o.dev != nil {
logger.Info("Got new message. Closing existing tunnel!")
o.dev.Close()
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &wgData); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
return
}
// A server-provided DNS config always overrides the client's own local
// config (from CLI flags / API connect request) - see applyDNSConfigUpdate.
if wgData.DNSConfig != nil {
o.applyDNSConfigUpdate(*wgData.DNSConfig)
}
// When handed an already-open FD (mobile/NetworkExtension platforms), the
// TUN device's addresses and routes are owned and reconciled by the host
// platform from NetworkSettings (e.g. Apple's NEPacketTunnelProvider via
// setTunnelNetworkSettings) - our own ifconfig/route subprocess calls must
// not also run against the same interface, or the two end up installing
// competing routes to the same destination. On macOS specifically this
// package's darwin code paths would otherwise run for real here (the NE
// build shares GOOS=darwin with the CLI), unlike iOS where they're already
// no-ops via a GOOS check.
network.NativeConfigDisabled = o.tunnelConfig.FileDescriptorTun != 0
o.tdev, err = func() (tun.Device, error) {
if o.tunnelConfig.FileDescriptorTun != 0 {
return olmDevice.CreateTUNFromFD(o.tunnelConfig.FileDescriptorTun, o.tunnelConfig.MTU)
}
ifName := o.tunnelConfig.InterfaceName
if runtime.GOOS == "darwin" { // this is if we dont pass a fd
ifName, err = network.FindUnusedUTUN()
if err != nil {
return nil, err
}
}
return tun.CreateTUN(ifName, o.tunnelConfig.MTU)
}()
if err != nil {
logger.Error("Failed to create TUN device: %v", err)
return
}
// if config.FileDescriptorTun == 0 {
if realInterfaceName, err2 := o.tdev.Name(); err2 == nil { // if the interface is defined then this should not really do anything?
o.tunnelConfig.InterfaceName = realInterfaceName
}
// }
// Wrap TUN device with packet filter for DNS proxy
o.middleDev = olmDevice.NewMiddleDevice(o.tdev)
wgLogger := logger.GetLogger().GetWireGuardLogger("wireguard: ")
// Use filtered device instead of raw TUN device
o.dev = device.NewDevice(o.middleDev, o.sharedBind, (*device.Logger)(wgLogger))
if o.tunnelConfig.EnableUAPI {
fileUAPI, err := func() (*os.File, error) {
if o.tunnelConfig.FileDescriptorUAPI != 0 {
fd, err := strconv.ParseUint(fmt.Sprintf("%d", o.tunnelConfig.FileDescriptorUAPI), 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid UAPI file descriptor: %v", err)
}
return os.NewFile(uintptr(fd), ""), nil
}
return olmDevice.UapiOpen(o.tunnelConfig.InterfaceName)
}()
if err != nil {
logger.Error("UAPI listen error: %v", err)
os.Exit(1)
return
}
o.uapiListener, err = olmDevice.UapiListen(o.tunnelConfig.InterfaceName, fileUAPI)
if err != nil {
logger.Error("Failed to listen on uapi socket: %v", err)
os.Exit(1)
}
go func() {
for {
conn, err := o.uapiListener.Accept()
if err != nil {
return
}
go o.dev.IpcHandle(conn)
}
}()
logger.Info("UAPI listener started")
}
if err = o.dev.Up(); err != nil {
logger.Error("Failed to bring up WireGuard device: %v", err)
}
// Set the private key unconditionally, since it's otherwise only ever set as a
// side effect of configuring a site peer (see peers.ConfigurePeer) - if there are
// no sites (e.g. an exit-node-only connection), the interface would otherwise be
// brought up with no private key configured at all.
if err := o.dev.IpcSet(fmt.Sprintf("private_key=%s\n", util.FixKey(o.privateKey.String()))); err != nil {
logger.Error("Failed to set private key on WireGuard device: %v", err)
}
// Extract interface IP (strip CIDR notation if present)
interfaceIP := wgData.TunnelIP
if strings.Contains(interfaceIP, "/") {
interfaceIP = strings.Split(interfaceIP, "/")[0]
}
if addr, err := netip.ParseAddr(interfaceIP); err == nil {
o.primaryTunnelIP = addr
} else {
logger.Warn("Failed to parse tunnel IP %q: %v", interfaceIP, err)
}
// The DNS proxy is always created - it resolves both exit node aliases
// (unaffected by DisableRoutesAndAliasesOnExitNode - see connectExitNode)
// and site resource aliases, which the peer manager created below adds
// and removes dynamically as an exit node connects/disconnects (see
// PeerManager.SetExitNode/ClearExitNode). o.dnsProxy is still nil-checked
// everywhere it's used, in case creation itself fails.
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS)
if err != nil {
logger.Error("Failed to create DNS proxy: %v", err)
}
// Tell the system DNS monitor to exclude the proxy IP so that subsequent
// polls never mistake the proxy for a real upstream server (on Linux the OS
// DNS is overridden to point at this IP, which would otherwise feed back
// into UpstreamDNS or PublicDNS on the next poll).
if o.dnsMonitor != nil && o.dnsProxy != nil {
o.dnsMonitor.SetExcludeIP(o.dnsProxy.GetProxyIP())
}
if err = network.ConfigureInterface(o.tunnelConfig.InterfaceName, wgData.TunnelIP, o.tunnelConfig.MTU); err != nil {
logger.Error("Failed to o.tunnelConfigure interface: %v", err)
}
if o.tunnelConfig.SubnetRouter {
if err := subnetrouter.Enable(o.tunnelConfig.InterfaceName, o.primaryTunnelIP); err != nil {
logger.Error("Failed to enable subnet router: %v", err)
} else {
logger.Info("Subnet router enabled on %s (SNAT to %s)", o.tunnelConfig.InterfaceName, o.primaryTunnelIP)
}
}
// The utility subnet route is what makes the DNS proxy (always created
// above) reachable at all, so it's always added too, independent of
// DisableRoutesAndAliasesOnExitNode - which only gates routes/aliases for
// individual site resources, applied dynamically below as the peer
// manager is told about the exit node's connection state.
if err := network.AddRoutesWithSource([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName, interfaceIP); err != nil { // also route the utility subnet
logger.Error("Failed to add route for utility subnet: %v", err)
}
// Create peer manager with integrated peer monitoring. If
// DisableRoutesAndAliasesOnExitNode is enabled, resource routes/aliases
// are suppressed reactively once an exit node (ExitNodeConfig peer or
// gateway mode) actually activates below/later - see
// PeerManager.SetExitNode/SetGateway - rather than being pre-seeded here,
// so a failed initial exit-node/gateway setup can never leave routes
// stuck suppressed with nothing active to justify it.
o.peerManager = peers.NewPeerManager(peers.PeerManagerConfig{
Device: o.dev,
DNSProxy: o.dnsProxy,
InterfaceName: o.tunnelConfig.InterfaceName,
PrivateKey: o.privateKey,
MiddleDev: o.middleDev,
LocalIP: interfaceIP,
SharedBind: o.sharedBind,
WSClient: o.websocket,
APIServer: o.apiServer,
PublicDNS: o.tunnelConfig.PublicDNS,
DisableRoutesAndAliasesOnExitNode: o.tunnelConfig.DisableRoutesAndAliasesOnExitNode,
})
for i := range wgData.Sites {
site := wgData.Sites[i]
if site.PublicKey != "" {
var siteEndpoint string
// here we are going to take the relay endpoint if it exists which means we requested a relay for this peer
if site.RelayEndpoint != "" {
siteEndpoint = site.RelayEndpoint
} else {
siteEndpoint = site.Endpoint
}
o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false, false)
}
// we still call this to add the aliases for jit lookup but we just do that then pass inside. need to skip the above so we dont add to the api
if err := o.peerManager.AddPeer(site); err != nil {
logger.Error("Failed to add peer: %v", err)
return
}
logger.Info("Configured peer %s", site.PublicKey)
}
o.peerManager.Start()
// OnTokenUpdate (see olm.go) typically fires before this peer manager
// existed - it runs during the initial token/auth fetch, well before this
// "olm/wg/connect" message - so push in whatever hole-punch bypass
// endpoints it already recorded now that there's somewhere to put them.
o.flushPendingHolepunchBypassEndpoints()
o.flushPendingDNSBypassEndpoints()
o.flushPendingControlBypassEndpoints()
if o.dnsProxy != nil {
if err := o.dnsProxy.Start(); err != nil { // start DNS proxy first so there is no downtime
logger.Error("Failed to start DNS proxy: %v", err)
}
// Register JIT handler: when the DNS proxy resolves a local record, check whether
// the owning site is already connected and, if not, initiate a JIT connection.
o.dnsProxy.SetJITHandler(func(siteId int) {
pm := o.getPeerManager()
if pm == nil || o.websocket == nil {
return
}
// Site already has an active peer connection - nothing to do.
if _, exists := pm.GetPeer(siteId); exists {
return
}
o.peerSendMu.Lock()
defer o.peerSendMu.Unlock()
// A JIT request for this site is already in-flight - avoid duplicate sends.
if _, pending := o.jitPendingSites[siteId]; pending {
return
}
chainId := generateChainId()
logger.Info("DNS-triggered JIT connect for site %d (chainId=%s)", siteId, chainId)
stopFunc, _ := o.websocket.SendMessageInterval("olm/wg/server/peer/init", map[string]interface{}{
"siteId": siteId,
"chainId": chainId,
}, 2*time.Second, 10)
o.stopPeerInits[chainId] = stopFunc
o.jitPendingSites[siteId] = chainId
})
}
if o.tunnelConfig.OverrideDNS && o.dnsProxy != nil {
if err := o.applyDNSOverride(true); err != nil {
logger.Error("%v", err)
return
}
}
if wgData.ExitNode != nil && wgData.ExitNode.Connect {
if err := o.connectExitNode(*wgData.ExitNode); err != nil {
logger.Error("Failed to connect to exit node: %v", err)
}
} else {
logger.Debug("No exit node to connect to (not provided, or connect flag is false)")
}
o.apiServer.SetRegistered(true)
o.registered = true
if len(o.tunnelConfig.GatewaySiteIds) > 0 {
o.applyPendingGatewayConfig(o.tunnelConfig.GatewaySiteResourceId, o.tunnelConfig.GatewaySiteIds)
}
// Start ping monitor now that we are registered and connected
o.websocket.StartPingMonitor()
// Invoke onConnected callback if configured
if o.olmConfig.OnConnected != nil {
go o.olmConfig.OnConnected()
}
logger.Info("WireGuard device created.")
}
func (o *Olm) handleOlmError(msg websocket.WSMessage) {
logger.Debug("Received olm error message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring olm error message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling olm error data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &errorData); err != nil {
logger.Error("Error unmarshaling olm error data: %v", err)
return
}
logger.Error("Olm error (code: %s): %s", errorData.Code, errorData.Message)
// Set the olm error in the API server so it can be exposed via status
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
// Invoke onOlmError callback if configured
if o.olmConfig.OnOlmError != nil {
go o.olmConfig.OnOlmError(errorData.Code, errorData.Message)
}
}
func (o *Olm) handleTerminate(msg websocket.WSMessage) {
logger.Info("Received terminate message")
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring terminate message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling terminate error data: %v", err)
} else {
if err := json.Unmarshal(jsonData, &errorData); err != nil {
logger.Error("Error unmarshaling terminate error data: %v", err)
} else {
logger.Info("Terminate reason (code: %s): %s", errorData.Code, errorData.Message)
if errorData.Code == "TERMINATED_INACTIVITY" {
logger.Debug("Ignoring TERMINATED_INACTIVITY message for now because we could have been sleeping...")
return
}
// Set the olm error in the API server so it can be exposed via status
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
}
}
o.apiServer.SetTerminated(true)
o.apiServer.SetConnectionStatus(false)
o.apiServer.SetRegistered(false)
o.apiServer.ClearPeerStatuses()
o.apiServer.SetGatewayStatus(false, 0, nil)
network.ClearNetworkSettings()
o.Close()
if o.olmConfig.OnTerminated != nil {
go o.olmConfig.OnTerminated()
}
}