mirror of
https://github.com/fosrl/olm.git
synced 2026-08-04 02:35:54 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f54e27b22 | ||
|
|
3542fee459 | ||
|
|
60c7703f07 | ||
|
|
d8714df81f | ||
|
|
6faa3a0273 | ||
|
|
fbc4fb2827 | ||
|
|
96e1d0f98c | ||
|
|
29663cdb81 | ||
|
|
23597835d8 | ||
|
|
8ef735185f | ||
|
|
99d249db2d | ||
|
|
9c3c04c728 | ||
|
|
39aaaafa17 | ||
|
|
3d88b321e8 | ||
|
|
e1214e21cd | ||
|
|
a2f0f64c2f | ||
|
|
0e06fb0152 | ||
|
|
7929ce9cf9 | ||
|
|
9513433c07 |
+32
-3
@@ -29,6 +29,7 @@ type ConnectionRequest struct {
|
||||
PingInterval string `json:"pingInterval,omitempty"`
|
||||
PingTimeout string `json:"pingTimeout,omitempty"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
MatchDomains []string `json:"matchDomains,omitempty"`
|
||||
}
|
||||
|
||||
// SwitchOrgRequest defines the structure for switching organizations
|
||||
@@ -50,6 +51,7 @@ type PeerStatus struct {
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
IsRelay bool `json:"isRelay"`
|
||||
IsLocal bool `json:"isLocal"` // true when connected via a local network endpoint, bypassing both the public endpoint and relay
|
||||
PeerIP string `json:"peerAddress,omitempty"`
|
||||
HolepunchConnected bool `json:"holepunchConnected"`
|
||||
}
|
||||
@@ -228,7 +230,7 @@ func (s *API) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
|
||||
func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
@@ -246,10 +248,11 @@ func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt tim
|
||||
status.LastSeen = time.Now()
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
status.IsLocal = isLocal
|
||||
}
|
||||
|
||||
// UpdatePeerStatus updates the status of a peer including endpoint and relay info
|
||||
func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
|
||||
// UpdatePeerStatus updates the status of a peer including endpoint, relay, and local info
|
||||
func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
@@ -266,6 +269,7 @@ func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, en
|
||||
status.LastSeen = time.Now()
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
status.IsLocal = isLocal
|
||||
}
|
||||
|
||||
func (s *API) RemovePeerStatus(siteID int) { // remove the peer from the status map
|
||||
@@ -362,6 +366,31 @@ func (s *API) UpdatePeerRelayStatus(siteID int, endpoint string, isRelay bool) {
|
||||
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
if isRelay {
|
||||
// Relay and local are mutually exclusive; local always wins when viable.
|
||||
status.IsLocal = false
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerLocalStatus updates only the local-connection status of a peer. A peer using a
|
||||
// local connection is never simultaneously relayed.
|
||||
func (s *API) UpdatePeerLocalStatus(siteID int, endpoint string, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
status, exists := s.peerStatuses[siteID]
|
||||
if !exists {
|
||||
status = &PeerStatus{
|
||||
SiteID: siteID,
|
||||
}
|
||||
s.peerStatuses[siteID] = status
|
||||
}
|
||||
|
||||
status.Endpoint = endpoint
|
||||
status.IsLocal = isLocal
|
||||
if isLocal {
|
||||
status.IsRelay = false
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerHolepunchStatus updates the holepunch connection status of a peer
|
||||
|
||||
@@ -27,6 +27,13 @@ type OlmConfig struct {
|
||||
UpstreamDNS []string `json:"upstreamDNS"`
|
||||
InterfaceName string `json:"interface"`
|
||||
|
||||
// MatchDomains lists FQDN wildcard patterns (using * and ? wildcards, e.g.
|
||||
// "*.proxy.internal") that olm should check against local records / resolve
|
||||
// via UpstreamDNS. Queries for domains that don't match any pattern are sent
|
||||
// directly to the host's own system DNS servers instead. Empty means match
|
||||
// every domain (i.e. the feature is disabled).
|
||||
MatchDomains []string `json:"matchDomainsDNS"`
|
||||
|
||||
// Logging
|
||||
LogLevel string `json:"logLevel"`
|
||||
|
||||
@@ -40,11 +47,12 @@ type OlmConfig struct {
|
||||
PingTimeout string `json:"pingTimeout"`
|
||||
|
||||
// Advanced
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
PreferLocalRoutes bool `json:"preferLocalRoutes"`
|
||||
// DoNotCreateNewClient bool `json:"doNotCreateNewClient"`
|
||||
|
||||
// Parsed values (not in JSON)
|
||||
@@ -99,6 +107,7 @@ func DefaultConfig() *OlmConfig {
|
||||
config.sources["mtu"] = string(SourceDefault)
|
||||
config.sources["dns"] = string(SourceDefault)
|
||||
config.sources["upstreamDNS"] = string(SourceDefault)
|
||||
config.sources["matchDomains"] = string(SourceDefault)
|
||||
config.sources["logLevel"] = string(SourceDefault)
|
||||
config.sources["interface"] = string(SourceDefault)
|
||||
config.sources["enableApi"] = string(SourceDefault)
|
||||
@@ -110,6 +119,7 @@ func DefaultConfig() *OlmConfig {
|
||||
config.sources["overrideDNS"] = string(SourceDefault)
|
||||
config.sources["tunnelDNS"] = string(SourceDefault)
|
||||
config.sources["disableRelay"] = string(SourceDefault)
|
||||
config.sources["preferLocalRoutes"] = string(SourceDefault)
|
||||
// config.sources["doNotCreateNewClient"] = string(SourceDefault)
|
||||
|
||||
return config
|
||||
@@ -229,6 +239,10 @@ func loadConfigFromEnv(config *OlmConfig) {
|
||||
config.UpstreamDNS = []string{val}
|
||||
config.sources["upstreamDNS"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("MATCH_DOMAINS_DNS"); val != "" {
|
||||
config.MatchDomains = splitComma(val)
|
||||
config.sources["matchDomains"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("LOG_LEVEL"); val != "" {
|
||||
config.LogLevel = val
|
||||
config.sources["logLevel"] = string(SourceEnv)
|
||||
@@ -269,6 +283,10 @@ func loadConfigFromEnv(config *OlmConfig) {
|
||||
config.DisableRelay = true
|
||||
config.sources["disableRelay"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("PREFER_LOCAL_ROUTES"); val == "true" {
|
||||
config.PreferLocalRoutes = true
|
||||
config.sources["preferLocalRoutes"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("TUNNEL_DNS"); val == "true" {
|
||||
config.TunnelDNS = true
|
||||
config.sources["tunnelDNS"] = string(SourceEnv)
|
||||
@@ -285,25 +303,27 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
|
||||
// Store original values to detect changes
|
||||
origValues := map[string]interface{}{
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"matchDomains": fmt.Sprintf("%v", config.MatchDomains),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"preferLocalRoutes": config.PreferLocalRoutes,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
// "doNotCreateNewClient": config.DoNotCreateNewClient,
|
||||
}
|
||||
|
||||
@@ -317,6 +337,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
serviceFlags.StringVar(&config.DNS, "dns", config.DNS, "DNS server to use")
|
||||
var upstreamDNSFlag string
|
||||
serviceFlags.StringVar(&upstreamDNSFlag, "upstream-dns", "", "Upstream DNS server(s) (comma-separated, default: 8.8.8.8:53)")
|
||||
var matchDomainsFlag string
|
||||
serviceFlags.StringVar(&matchDomainsFlag, "match-domains-dns", "", "FQDN wildcard patterns (comma-separated, e.g. '*.proxy.internal,*.host-0?.autoco.internal') to check against local records/upstream DNS; queries for non-matching domains are sent directly to the system's DNS servers (default: match all domains)")
|
||||
serviceFlags.StringVar(&config.LogLevel, "log-level", config.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
serviceFlags.StringVar(&config.InterfaceName, "interface", config.InterfaceName, "Name of the WireGuard interface")
|
||||
serviceFlags.StringVar(&config.HTTPAddr, "http-addr", config.HTTPAddr, "HTTP server address (e.g., ':9452')")
|
||||
@@ -327,6 +349,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
serviceFlags.BoolVar(&config.DisableHolepunch, "disable-holepunch", config.DisableHolepunch, "Disable hole punching")
|
||||
serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server. (default false)")
|
||||
serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections")
|
||||
serviceFlags.BoolVar(&config.PreferLocalRoutes, "prefer-local-routes", config.PreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)")
|
||||
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)")
|
||||
// serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client")
|
||||
|
||||
@@ -348,6 +371,11 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse match domains flag if provided
|
||||
if matchDomainsFlag != "" {
|
||||
config.MatchDomains = splitComma(matchDomainsFlag)
|
||||
}
|
||||
|
||||
// Track which values were changed by CLI args
|
||||
if config.Endpoint != origValues["endpoint"].(string) {
|
||||
config.sources["endpoint"] = string(SourceCLI)
|
||||
@@ -373,6 +401,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
if fmt.Sprintf("%v", config.UpstreamDNS) != origValues["upstreamDNS"].(string) {
|
||||
config.sources["upstreamDNS"] = string(SourceCLI)
|
||||
}
|
||||
if fmt.Sprintf("%v", config.MatchDomains) != origValues["matchDomains"].(string) {
|
||||
config.sources["matchDomains"] = string(SourceCLI)
|
||||
}
|
||||
if config.LogLevel != origValues["logLevel"].(string) {
|
||||
config.sources["logLevel"] = string(SourceCLI)
|
||||
}
|
||||
@@ -403,6 +434,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
if config.DisableRelay != origValues["disableRelay"].(bool) {
|
||||
config.sources["disableRelay"] = string(SourceCLI)
|
||||
}
|
||||
if config.PreferLocalRoutes != origValues["preferLocalRoutes"].(bool) {
|
||||
config.sources["preferLocalRoutes"] = string(SourceCLI)
|
||||
}
|
||||
if config.TunnelDNS != origValues["tunnelDNS"].(bool) {
|
||||
config.sources["tunnelDNS"] = string(SourceCLI)
|
||||
}
|
||||
@@ -481,6 +515,10 @@ func mergeConfigs(dest, src *OlmConfig) {
|
||||
dest.UpstreamDNS = src.UpstreamDNS
|
||||
dest.sources["upstreamDNS"] = string(SourceFile)
|
||||
}
|
||||
if len(src.MatchDomains) > 0 {
|
||||
dest.MatchDomains = src.MatchDomains
|
||||
dest.sources["matchDomains"] = string(SourceFile)
|
||||
}
|
||||
if src.LogLevel != "" && src.LogLevel != "INFO" {
|
||||
dest.LogLevel = src.LogLevel
|
||||
dest.sources["logLevel"] = string(SourceFile)
|
||||
@@ -530,6 +568,10 @@ func mergeConfigs(dest, src *OlmConfig) {
|
||||
dest.DisableRelay = src.DisableRelay
|
||||
dest.sources["disableRelay"] = string(SourceFile)
|
||||
}
|
||||
if src.PreferLocalRoutes {
|
||||
dest.PreferLocalRoutes = src.PreferLocalRoutes
|
||||
dest.sources["preferLocalRoutes"] = string(SourceFile)
|
||||
}
|
||||
// if src.DoNotCreateNewClient {
|
||||
// dest.DoNotCreateNewClient = src.DoNotCreateNewClient
|
||||
// dest.sources["doNotCreateNewClient"] = string(SourceFile)
|
||||
@@ -598,6 +640,7 @@ func (c *OlmConfig) ShowConfig() {
|
||||
fmt.Printf(" mtu = %d [%s]\n", c.MTU, getSource("mtu"))
|
||||
fmt.Printf(" dns = %s [%s]\n", c.DNS, getSource("dns"))
|
||||
fmt.Printf(" upstream-dns = %v [%s]\n", c.UpstreamDNS, getSource("upstreamDNS"))
|
||||
fmt.Printf(" match-domains-dns = %v [%s]\n", c.MatchDomains, getSource("matchDomains"))
|
||||
fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface"))
|
||||
|
||||
// Logging
|
||||
@@ -621,6 +664,7 @@ func (c *OlmConfig) ShowConfig() {
|
||||
fmt.Printf(" override-dns = %v [%s]\n", c.OverrideDNS, getSource("overrideDNS"))
|
||||
fmt.Printf(" tunnel-dns = %v [%s]\n", c.TunnelDNS, getSource("tunnelDNS"))
|
||||
fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay"))
|
||||
fmt.Printf(" prefer-local-routes = %v [%s]\n", c.PreferLocalRoutes, getSource("preferLocalRoutes"))
|
||||
// fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient"))
|
||||
if c.TlsClientCert != "" {
|
||||
fmt.Printf(" tls-cert = %s [%s]\n", c.TlsClientCert, getSource("tlsClientCert"))
|
||||
|
||||
+108
-40
@@ -1,6 +1,7 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/bind"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
@@ -24,7 +26,7 @@ type FilterRule struct {
|
||||
// closeAwareDevice wraps a tun.Device along with a flag
|
||||
// indicating whether its Close method was called.
|
||||
type closeAwareDevice struct {
|
||||
isClosed atomic.Bool
|
||||
isClosed atomic.Bool
|
||||
tun.Device
|
||||
closeEventCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
@@ -423,6 +425,33 @@ func extractDestIP(packet []byte) (netip.Addr, bool) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// extractUDPPayload returns the UDP payload of packet, if packet is a well-formed
|
||||
// IPv4 or IPv6 UDP datagram (ignoring IPv6 extension headers).
|
||||
func extractUDPPayload(packet []byte) ([]byte, bool) {
|
||||
if len(packet) < 20 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
const udpProtocol = 17
|
||||
|
||||
switch packet[0] >> 4 {
|
||||
case 4:
|
||||
ihl := int(packet[0]&0x0f) * 4
|
||||
if ihl < 20 || len(packet) < ihl+8 || packet[9] != udpProtocol {
|
||||
return nil, false
|
||||
}
|
||||
return packet[ihl+8:], true
|
||||
case 6:
|
||||
const ipv6HeaderLen = 40
|
||||
if len(packet) < ipv6HeaderLen+8 || packet[6] != udpProtocol {
|
||||
return nil, false
|
||||
}
|
||||
return packet[ipv6HeaderLen+8:], true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Read intercepts packets going UP from the TUN device (towards WireGuard)
|
||||
func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||
for {
|
||||
@@ -497,17 +526,19 @@ func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err
|
||||
rules := d.rules
|
||||
d.rulesMutex.RUnlock()
|
||||
|
||||
if len(rules) == 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Process packets and filter out handled ones
|
||||
// Process packets and filter out handled ones. This always runs (even with
|
||||
// no per-IP rules registered) so magic connectivity-test packets can be
|
||||
// dropped before they reach WireGuard - see isLeakedMagicPacket.
|
||||
writeIdx := 0
|
||||
for readIdx := 0; readIdx < n; readIdx++ {
|
||||
packet := bufs[readIdx][offset : offset+sizes[readIdx]]
|
||||
|
||||
if isLeakedMagicPacket(packet) {
|
||||
continue
|
||||
}
|
||||
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
if !ok || len(rules) == 0 {
|
||||
if writeIdx != readIdx {
|
||||
bufs[writeIdx] = bufs[readIdx]
|
||||
sizes[writeIdx] = sizes[readIdx]
|
||||
@@ -539,6 +570,74 @@ func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err
|
||||
}
|
||||
}
|
||||
|
||||
// isLeakedMagicPacket reports whether packet carries one of our UDP connectivity-test
|
||||
// magic payloads (see bind.IsMagicPacket). These packets are sent directly between
|
||||
// physical UDP sockets by the local-endpoint holepunch tester and must never be
|
||||
// encapsulated by WireGuard: if OS routing sends one into this TUN interface instead
|
||||
// of out the real network interface (e.g. because the destination falls inside a
|
||||
// routed tunnel subnet), tunneling and echoing it back would make a LAN-local
|
||||
// endpoint falsely appear directly reachable. Dropping it here makes the test
|
||||
// correctly time out instead.
|
||||
func isLeakedMagicPacket(packet []byte) bool {
|
||||
payload, ok := extractUDPPayload(packet)
|
||||
return ok && isMagicPacket(payload)
|
||||
}
|
||||
|
||||
// IsMagicPacket reports whether payload is one of our connectivity-test magic
|
||||
// packets (a MagicTestRequest or MagicTestResponse). These packets are meant to
|
||||
// travel directly between physical UDP sockets and must never be encapsulated by
|
||||
// WireGuard - e.g. if OS routing mistakenly sends one into a WireGuard TUN
|
||||
// interface (because the destination falls inside a routed tunnel subnet), it
|
||||
// should be dropped there rather than tunneled, which would otherwise make a
|
||||
// LAN-local endpoint test falsely appear to succeed over the tunnel.
|
||||
func isMagicPacket(payload []byte) bool {
|
||||
if len(payload) >= bind.MagicTestRequestLen && bytes.HasPrefix(payload, bind.MagicTestRequest) {
|
||||
return true
|
||||
}
|
||||
if len(payload) >= bind.MagicTestResponseLen && bytes.HasPrefix(payload, bind.MagicTestResponse) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterDownstreamBufs drops packets going DOWN to the TUN device (from WireGuard)
|
||||
// that are handled by a per-IP rule or are a leaked magic connectivity-test packet
|
||||
// (see isLeakedMagicPacket) - always checked, even with no rules registered. It
|
||||
// returns bufs unchanged (no allocation) unless a packet actually needs to be
|
||||
// dropped, at which point it switches to an owned copy of the buffers kept so far.
|
||||
func filterDownstreamBufs(bufs [][]byte, rules []FilterRule, offset int) [][]byte {
|
||||
filtered := bufs
|
||||
for i, buf := range bufs {
|
||||
drop := len(buf) <= offset
|
||||
if !drop {
|
||||
packet := buf[offset:]
|
||||
if isLeakedMagicPacket(packet) {
|
||||
drop = true
|
||||
} else if destIP, ok := extractDestIP(packet); ok && len(rules) > 0 {
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP && rule.Handler(packet) {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if drop {
|
||||
if len(filtered) == len(bufs) {
|
||||
// First drop: switch to an owned, growable copy of everything kept so far.
|
||||
filtered = append([][]byte(nil), bufs[:i]...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(filtered) != len(bufs) {
|
||||
filtered = append(filtered, buf)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Write intercepts packets going DOWN to the TUN device (from WireGuard)
|
||||
func (d *MiddleDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
for {
|
||||
@@ -558,38 +657,7 @@ func (d *MiddleDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
rules := d.rules
|
||||
d.rulesMutex.RUnlock()
|
||||
|
||||
var filteredBufs [][]byte
|
||||
if len(rules) == 0 {
|
||||
filteredBufs = bufs
|
||||
} else {
|
||||
filteredBufs = make([][]byte, 0, len(bufs))
|
||||
for _, buf := range bufs {
|
||||
if len(buf) <= offset {
|
||||
continue
|
||||
}
|
||||
|
||||
packet := buf[offset:]
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
continue
|
||||
}
|
||||
|
||||
handled := false
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP {
|
||||
if rule.Handler(packet) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredBufs := filterDownstreamBufs(bufs, rules, offset)
|
||||
|
||||
if len(filteredBufs) == 0 {
|
||||
return len(bufs), nil
|
||||
@@ -660,4 +728,4 @@ func (d *MiddleDevice) WriteToTun(bufs [][]byte, offset int) (int, error) {
|
||||
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,22 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/fosrl/newt/bind"
|
||||
"github.com/fosrl/newt/util"
|
||||
)
|
||||
|
||||
// buildIPv4UDPPacket builds a minimal IPv4/UDP packet (no options) carrying payload.
|
||||
func buildIPv4UDPPacket(payload []byte) []byte {
|
||||
const ipHeaderLen = 20
|
||||
const udpHeaderLen = 8
|
||||
|
||||
packet := make([]byte, ipHeaderLen+udpHeaderLen+len(payload))
|
||||
packet[0] = 0x45 // version 4, IHL 5
|
||||
packet[9] = 17 // protocol: UDP
|
||||
copy(packet[ipHeaderLen+udpHeaderLen:], payload)
|
||||
return packet
|
||||
}
|
||||
|
||||
func TestExtractDestIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -88,6 +101,49 @@ func TestGetProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeakedMagicPacket(t *testing.T) {
|
||||
request := make([]byte, bind.MagicTestRequestLen)
|
||||
copy(request, bind.MagicTestRequest)
|
||||
|
||||
response := make([]byte, bind.MagicTestResponseLen)
|
||||
copy(response, bind.MagicTestResponse)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
packet []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "magic test request leaked into tunnel",
|
||||
packet: buildIPv4UDPPacket(request),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "magic test response leaked into tunnel",
|
||||
packet: buildIPv4UDPPacket(response),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary UDP payload",
|
||||
packet: buildIPv4UDPPacket([]byte("just some ordinary application data")),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "too short to be a packet",
|
||||
packet: []byte{0x45, 0x00},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLeakedMagicPacket(tt.packet); got != tt.want {
|
||||
t.Errorf("isLeakedMagicPacket() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExtractDestIP(b *testing.B) {
|
||||
packet := []byte{
|
||||
0x45, 0x00, 0x00, 0x54, 0x00, 0x00, 0x40, 0x00,
|
||||
@@ -100,3 +156,61 @@ func BenchmarkExtractDestIP(b *testing.B) {
|
||||
extractDestIP(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDownstreamBufsNoDropIsAllocFree(t *testing.T) {
|
||||
bufs := make([][]byte, 128)
|
||||
for i := range bufs {
|
||||
bufs[i] = buildIPv4UDPPacket(make([]byte, 1372))
|
||||
}
|
||||
|
||||
allocs := testing.AllocsPerRun(1000, func() {
|
||||
out := filterDownstreamBufs(bufs, nil, 0)
|
||||
if len(out) != len(bufs) {
|
||||
t.Fatalf("expected no packets dropped, got %d/%d", len(out), len(bufs))
|
||||
}
|
||||
})
|
||||
|
||||
if allocs != 0 {
|
||||
t.Errorf("filterDownstreamBufs() with nothing to drop allocated %v times per call, want 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDownstreamBufsDropsMagicPacket(t *testing.T) {
|
||||
request := make([]byte, bind.MagicTestRequestLen)
|
||||
copy(request, bind.MagicTestRequest)
|
||||
|
||||
bufs := [][]byte{
|
||||
buildIPv4UDPPacket([]byte("ordinary payload one")),
|
||||
buildIPv4UDPPacket(request),
|
||||
buildIPv4UDPPacket([]byte("ordinary payload two")),
|
||||
}
|
||||
|
||||
out := filterDownstreamBufs(bufs, nil, 0)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 1 packet dropped, got %d remaining", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFilterDownstreamBufsNoDrop(b *testing.B) {
|
||||
bufs := make([][]byte, 128)
|
||||
for i := range bufs {
|
||||
bufs[i] = buildIPv4UDPPacket(make([]byte, 1372))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
filterDownstreamBufs(bufs, nil, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIsLeakedMagicPacket(b *testing.B) {
|
||||
// A typical ~1400 byte ordinary application payload (the common case on the
|
||||
// hot path - almost every real packet should look like this).
|
||||
ordinary := buildIPv4UDPPacket(make([]byte, 1372))
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
isLeakedMagicPacket(ordinary)
|
||||
}
|
||||
}
|
||||
|
||||
+117
-2
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,20 @@ type DNSProxy struct {
|
||||
middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering and TUN writes
|
||||
recordStore *DNSRecordStore // Local DNS records
|
||||
|
||||
// matchDomains lists the FQDN wildcard patterns (using * and ? wildcards, see
|
||||
// matchWildcard) that this proxy is responsible for. Queries whose name matches
|
||||
// one of these patterns are checked against local records and, failing that,
|
||||
// forwarded to upstreamDNS. Queries that match none of the patterns are sent
|
||||
// directly to localDNS instead, bypassing local records and upstreamDNS
|
||||
// entirely. An empty matchDomains means "match everything" (i.e. behave as if
|
||||
// this feature were not configured).
|
||||
matchDomains []string
|
||||
// localDNS holds the host's own system DNS servers (as reported by
|
||||
// SystemDNSMonitor / PublicDNS), used to resolve queries that don't match
|
||||
// matchDomains rather than sending them upstream or through the tunnel.
|
||||
localDNS []string
|
||||
matchMu sync.RWMutex
|
||||
|
||||
// Tunnel DNS fields - for sending queries over WireGuard
|
||||
tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries)
|
||||
tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries
|
||||
@@ -55,8 +70,14 @@ type DNSProxy struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewDNSProxy creates a new DNS proxy
|
||||
func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string) (*DNSProxy, error) {
|
||||
// NewDNSProxy creates a new DNS proxy.
|
||||
//
|
||||
// matchDomains, if non-empty, restricts local-record lookup and upstream
|
||||
// forwarding to queries whose name matches one of the given wildcard patterns
|
||||
// (see matchWildcard). Queries that match none of the patterns are instead
|
||||
// forwarded directly to localDNS (the host's own system DNS servers). Pass an
|
||||
// empty matchDomains to match every query, preserving prior behavior.
|
||||
func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string, matchDomains []string, localDNS []string) (*DNSProxy, error) {
|
||||
proxyIP, err := PickIPFromSubnet(utilitySubnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err)
|
||||
@@ -76,6 +97,8 @@ func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet strin
|
||||
tunnelDNS: tunnelDns,
|
||||
recordStore: NewDNSRecordStore(),
|
||||
tunnelActivePorts: make(map[uint16]bool),
|
||||
matchDomains: matchDomains,
|
||||
localDNS: localDNS,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -383,6 +406,27 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
|
||||
question := msg.Question[0]
|
||||
logger.Debug("DNS query for %s (type %s)", question.Name, dns.TypeToString[question.Qtype])
|
||||
|
||||
// If matchDomains is configured and this query's name doesn't match any of
|
||||
// the configured patterns, skip local records and upstream entirely and
|
||||
// send it straight to the host's own system DNS servers.
|
||||
if !p.matchesConfiguredDomains(question.Name) {
|
||||
logger.Debug("Query for %s does not match configured domains, forwarding to local DNS %v", question.Name, p.getLocalDNS())
|
||||
response := p.forwardToLocalDNS(msg)
|
||||
if response == nil {
|
||||
logger.Error("Failed to get DNS response for %s from local DNS", question.Name)
|
||||
return
|
||||
}
|
||||
responseData, err := response.Pack()
|
||||
if err != nil {
|
||||
logger.Error("Failed to pack DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := udpConn.WriteTo(responseData, clientAddr); err != nil {
|
||||
logger.Error("Failed to send DNS response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have local records for this query
|
||||
var response *dns.Msg
|
||||
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA || question.Qtype == dns.TypePTR {
|
||||
@@ -505,6 +549,77 @@ func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns
|
||||
return response
|
||||
}
|
||||
|
||||
// matchesConfiguredDomains reports whether name matches one of the configured
|
||||
// matchDomains wildcard patterns. If matchDomains is empty, every name is
|
||||
// considered a match (i.e. the feature is disabled).
|
||||
func (p *DNSProxy) matchesConfiguredDomains(name string) bool {
|
||||
p.matchMu.RLock()
|
||||
patterns := p.matchDomains
|
||||
p.matchMu.RUnlock()
|
||||
|
||||
if len(patterns) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
name = strings.ToLower(dns.Fqdn(name))
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.ToLower(dns.Fqdn(pattern))
|
||||
if matchWildcard(pattern, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getLocalDNS returns the currently configured local (system) DNS servers.
|
||||
func (p *DNSProxy) getLocalDNS() []string {
|
||||
p.matchMu.RLock()
|
||||
defer p.matchMu.RUnlock()
|
||||
return p.localDNS
|
||||
}
|
||||
|
||||
// forwardToLocalDNS forwards a DNS query directly to the host's own system DNS
|
||||
// servers (localDNS), always using host networking regardless of tunnelDNS -
|
||||
// these queries are for domains the caller has explicitly excluded from
|
||||
// Pangolin resolution, so they should never traverse the tunnel.
|
||||
func (p *DNSProxy) forwardToLocalDNS(query *dns.Msg) *dns.Msg {
|
||||
servers := p.getLocalDNS()
|
||||
if len(servers) == 0 {
|
||||
logger.Warn("No local DNS servers configured, dropping query for %s", query.Question[0].Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, server := range servers {
|
||||
response, err := p.queryUpstreamDirect(server, query, 2*time.Second)
|
||||
if err == nil {
|
||||
return response
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
logger.Error("All local DNS servers failed: %v", lastErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMatchDomains replaces the list of wildcard domain patterns (see
|
||||
// matchWildcard) that this proxy checks against local records / upstream DNS.
|
||||
// Queries not matching any pattern are sent to localDNS instead. Pass an
|
||||
// empty slice to match every query (i.e. disable filtering).
|
||||
func (p *DNSProxy) SetMatchDomains(patterns []string) {
|
||||
p.matchMu.Lock()
|
||||
defer p.matchMu.Unlock()
|
||||
p.matchDomains = patterns
|
||||
}
|
||||
|
||||
// SetLocalDNS replaces the list of local (host system) DNS servers used to
|
||||
// resolve queries that don't match matchDomains. Servers must be in
|
||||
// "host:port" format (e.g. "192.168.1.1:53").
|
||||
func (p *DNSProxy) SetLocalDNS(servers []string) {
|
||||
p.matchMu.Lock()
|
||||
defer p.matchMu.Unlock()
|
||||
p.localDNS = servers
|
||||
}
|
||||
|
||||
// forwardToUpstream forwards a DNS query to upstream DNS servers
|
||||
func (p *DNSProxy) forwardToUpstream(query *dns.Msg) *dns.Msg {
|
||||
// Try primary DNS server
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/fosrl/newt v1.14.0
|
||||
github.com/fosrl/newt v1.15.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/miekg/dns v1.1.70
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/fosrl/newt v1.14.0 h1:9jpyfCNAtsH7rPojyIGJwqOqnLZdxm8b+njY5ZuY/6c=
|
||||
github.com/fosrl/newt v1.14.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o=
|
||||
github.com/fosrl/newt v1.15.0 h1:WpL0whZM1FMjUe2Vy5jSH1bgbxm1O9k1qCyF/mqZT+s=
|
||||
github.com/fosrl/newt v1.15.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
|
||||
@@ -263,6 +263,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
MTU: config.MTU,
|
||||
DNS: config.DNS,
|
||||
UpstreamDNS: config.UpstreamDNS,
|
||||
MatchDomains: config.MatchDomains,
|
||||
InterfaceName: config.InterfaceName,
|
||||
Holepunch: !config.DisableHolepunch,
|
||||
TlsClientCert: config.TlsClientCert,
|
||||
@@ -271,6 +272,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
OrgID: config.OrgID,
|
||||
OverrideDNS: config.OverrideDNS,
|
||||
DisableRelay: config.DisableRelay,
|
||||
PreferLocalRoutes: config.PreferLocalRoutes,
|
||||
EnableUAPI: true,
|
||||
}
|
||||
go olm.StartTunnel(tunnelConfig)
|
||||
|
||||
+2
-2
@@ -145,7 +145,7 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
}
|
||||
|
||||
// Create and start DNS proxy
|
||||
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP)
|
||||
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)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
siteEndpoint = site.Endpoint
|
||||
}
|
||||
|
||||
o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false)
|
||||
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
|
||||
|
||||
@@ -231,6 +231,7 @@ func (o *Olm) registerAPICallbacks() {
|
||||
Holepunch: req.Holepunch,
|
||||
TlsClientCert: req.TlsClientCert,
|
||||
OrgID: req.OrgID,
|
||||
MatchDomains: req.MatchDomains,
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -397,6 +398,7 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
|
||||
|
||||
o.tunnelRunning = true // Also set it here in case it is called externally
|
||||
o.tunnelConfig = config
|
||||
network.PreferLocalRoutes = config.PreferLocalRoutes
|
||||
|
||||
// Determine whether the system DNS monitor should also manage UpstreamDNS.
|
||||
// If the caller did not provide an explicit UpstreamDNS (it was defaulted to
|
||||
@@ -427,6 +429,11 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
|
||||
if pm := o.getPeerManager(); pm != nil {
|
||||
pm.SetPublicDNS(servers)
|
||||
}
|
||||
// Keep the DNS proxy's local-DNS fallback (used for MatchDomains
|
||||
// misses) in sync with the host's real system DNS servers.
|
||||
if o.dnsProxy != nil {
|
||||
o.dnsProxy.SetLocalDNS(servers)
|
||||
}
|
||||
|
||||
// UpstreamDNS is updated only when the caller did not supply an
|
||||
// explicit value; dynamic updates keep the proxy forwarding to the
|
||||
@@ -530,6 +537,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
|
||||
o.websocket.RegisterHandler("olm/wg/peer/update", o.handleWgPeerUpdate)
|
||||
o.websocket.RegisterHandler("olm/wg/peer/relay", o.handleWgPeerRelay)
|
||||
o.websocket.RegisterHandler("olm/wg/peer/unrelay", o.handleWgPeerUnrelay)
|
||||
o.websocket.RegisterHandler("olm/wg/peer/local", o.handleWgPeerLocal)
|
||||
o.websocket.RegisterHandler("olm/wg/peer/unlocal", o.handleWgPeerUnlocal)
|
||||
|
||||
// Handlers for managing remote subnets to a peer
|
||||
o.websocket.RegisterHandler("olm/wg/peer/data/add", o.handleWgPeerAddData)
|
||||
|
||||
+65
@@ -293,6 +293,71 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
|
||||
pm.UnRelayPeer(relayData.SiteId, primaryRelay)
|
||||
}
|
||||
|
||||
// handleWgPeerLocal handles the server's acknowledgement of an "olm/wg/local" message.
|
||||
// olm already switched the peer to the local endpoint before sending that message (it
|
||||
// doesn't wait for permission, unlike relay), so all this needs to do is stop the retry
|
||||
// sender for the given chain.
|
||||
func (o *Olm) handleWgPeerLocal(msg websocket.WSMessage) {
|
||||
logger.Debug("Received local-peer ack message: %v", msg.Data)
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring local ack message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var localData struct {
|
||||
peers.LocalPeerAckData
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &localData); err != nil {
|
||||
logger.Error("Error unmarshaling local ack data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelLocalSend(localData.ChainId)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWgPeerUnlocal handles the server's acknowledgement of an "olm/wg/unlocal" message.
|
||||
// Same as handleWgPeerLocal, olm has already fallen back from the local endpoint by the time
|
||||
// it sends the notification, so this just stops the retry sender.
|
||||
func (o *Olm) handleWgPeerUnlocal(msg websocket.WSMessage) {
|
||||
logger.Debug("Received unlocal-peer ack message: %v", msg.Data)
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring unlocal ack message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var localData struct {
|
||||
peers.LocalPeerAckData
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &localData); err != nil {
|
||||
logger.Error("Error unmarshaling unlocal ack data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelLocalSend(localData.ChainId)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
|
||||
logger.Debug("Received peer-handshake message: %v", msg.Data)
|
||||
|
||||
|
||||
@@ -79,6 +79,13 @@ type TunnelConfig struct {
|
||||
PublicDNS []string
|
||||
InterfaceName string
|
||||
|
||||
// MatchDomains lists FQDN wildcard patterns (using * and ? wildcards) that
|
||||
// olm should check against local records / resolve via UpstreamDNS. Queries
|
||||
// that don't match any pattern are sent directly to the host's own system
|
||||
// DNS servers (PublicDNS) instead of being handled by the DNS proxy at all.
|
||||
// An empty MatchDomains matches every query, preserving prior behavior.
|
||||
MatchDomains []string
|
||||
|
||||
// Advanced
|
||||
Holepunch bool
|
||||
TlsClientCert string
|
||||
@@ -102,4 +109,12 @@ type TunnelConfig struct {
|
||||
InitialPostures map[string]any
|
||||
|
||||
DisableRelay bool
|
||||
|
||||
// PreferLocalRoutes, when enabled, adds tunnel routes with an explicit
|
||||
// high metric/priority so that an overlapping local/connected route to
|
||||
// the same destination always takes precedence over the VPN route,
|
||||
// rather than the two racing based on insertion order. Defaults to
|
||||
// false, preserving the routing behavior from before this option was
|
||||
// introduced.
|
||||
PreferLocalRoutes bool
|
||||
}
|
||||
|
||||
+198
-11
@@ -58,8 +58,29 @@ type PeerManager struct {
|
||||
|
||||
routeOptimizerStop chan struct{}
|
||||
optimizerTrigger chan struct{}
|
||||
|
||||
// lastOwnerChange tracks, per allowed-IP CIDR, when ownership was last transferred.
|
||||
// Used to enforce a cooldown so routes don't flap between two similarly-performing sites.
|
||||
lastOwnerChange map[string]time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// routeSwitchRTTMargin requires a candidate site's RTT to be at least this much
|
||||
// better (as a fraction) than the current owner's before we consider it worth
|
||||
// switching, so two similarly-performing sites don't flap back and forth.
|
||||
routeSwitchRTTMargin = 0.20 // candidate must be >=20% faster
|
||||
|
||||
// routeSwitchMinAbsMargin is a floor on the RTT improvement required, so the
|
||||
// percentage margin above doesn't become meaningless at very low RTTs (e.g. a
|
||||
// 1ms vs 0.8ms "20% improvement" shouldn't trigger a switch).
|
||||
routeSwitchMinAbsMargin = 5 * time.Millisecond
|
||||
|
||||
// routeSwitchCooldown is the minimum time to wait after transferring ownership
|
||||
// of a route before it can be transferred again, unless the current owner's
|
||||
// connection quality degrades (disconnects or falls back to relay).
|
||||
routeSwitchCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
|
||||
func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
pm := &PeerManager{
|
||||
@@ -72,6 +93,7 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
allowedIPClaims: make(map[string]map[int]bool),
|
||||
APIServer: config.APIServer,
|
||||
publicDNS: config.PublicDNS,
|
||||
lastOwnerChange: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Create the peer monitor
|
||||
@@ -86,6 +108,8 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
|
||||
pm.optimizerTrigger = make(chan struct{}, 1)
|
||||
|
||||
pm.peerMonitor.SetLocalConnectionCallbacks(pm.LocalPeer, pm.UnLocalPeer)
|
||||
|
||||
return pm
|
||||
}
|
||||
|
||||
@@ -181,7 +205,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
|
||||
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
|
||||
|
||||
err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint) // always use the real site endpoint for hole punch monitoring
|
||||
err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint, siteConfig.LocalEndpoints) // always use the real site endpoint for hole punch monitoring
|
||||
if err != nil {
|
||||
logger.Warn("Failed to setup monitoring for site %d: %v", siteConfig.SiteId, err)
|
||||
} else {
|
||||
@@ -190,11 +214,11 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
|
||||
pm.peers[siteConfig.SiteId] = siteConfig
|
||||
|
||||
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false)
|
||||
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false, false)
|
||||
|
||||
// Perform rapid initial holepunch test (outside of lock to avoid blocking)
|
||||
// This quickly determines if holepunch is viable and triggers relay if not
|
||||
go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint)
|
||||
go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint, siteConfig.LocalEndpoints)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -257,7 +281,7 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
|
||||
}
|
||||
}
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{subnet}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to remove route for remote subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
@@ -326,6 +350,10 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
return fmt.Errorf("peer with site ID %d not found", siteConfig.SiteId)
|
||||
}
|
||||
|
||||
// Preserve the currently active local endpoint (if any) across updates so an in-progress
|
||||
// local connection isn't disrupted by an unrelated site update.
|
||||
siteConfig.ActiveLocalEndpoint = oldPeer.ActiveLocalEndpoint
|
||||
|
||||
// Update aliases
|
||||
// Remove old aliases
|
||||
for _, alias := range oldPeer.Aliases {
|
||||
@@ -459,7 +487,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
}
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{subnet}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to remove route for subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
@@ -473,6 +501,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
|
||||
pm.peerMonitor.UpdateHolepunchEndpoint(siteConfig.SiteId, siteConfig.Endpoint)
|
||||
pm.peerMonitor.UpdateLocalEndpoints(siteConfig.SiteId, siteConfig.LocalEndpoints)
|
||||
|
||||
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
|
||||
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
|
||||
@@ -508,6 +537,7 @@ func (pm *PeerManager) releaseAllowedIP(siteId int, cidr string) (newOwner int,
|
||||
delete(claims, siteId)
|
||||
if len(claims) == 0 {
|
||||
delete(pm.allowedIPClaims, cidr)
|
||||
delete(pm.lastOwnerChange, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,7 +756,7 @@ func (pm *PeerManager) RemoveRemoteSubnet(siteId int, ip string) error {
|
||||
|
||||
// Only remove route if no other peer needs it
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{ip}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{ip}, pm.interfaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -814,6 +844,11 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error {
|
||||
func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string, relayPort uint16) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists && peer.ActiveLocalEndpoint != "" {
|
||||
pm.mu.Unlock()
|
||||
logger.Info("Ignoring relay request for site %d: local connection is active", siteId)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
// Store the relay endpoint
|
||||
peer.RelayEndpoint = relayEndpoint
|
||||
@@ -856,15 +891,43 @@ endpoint=%s:%d`, util.FixKey(peer.PublicKey), formattedEndpoint, relayPort)
|
||||
}
|
||||
|
||||
// performRapidInitialTest performs a rapid holepunch test for a newly added peer.
|
||||
// If the test fails, it immediately requests relay to minimize connection delay.
|
||||
// This runs in a goroutine to avoid blocking AddPeer.
|
||||
func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string) {
|
||||
// It races a test of the public endpoint against a test of the local candidate endpoints
|
||||
// (if any) and waits for both to finish before acting, so we never request relay only to
|
||||
// have it immediately superseded by a local connection (or vice versa). Local wins if it's
|
||||
// viable at all; otherwise relay is requested only if the public endpoint isn't viable.
|
||||
// This runs in a goroutine to avoid blocking AddPeer - the peer already starts out pointed
|
||||
// at the public endpoint (set synchronously in AddPeer), so this just settles the peer onto
|
||||
// its steady-state connection within ~1-2 seconds.
|
||||
func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string, localEndpoints []string) {
|
||||
if pm.peerMonitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Perform rapid test - this takes ~1-2 seconds max
|
||||
holepunchViable := pm.peerMonitor.RapidTestPeer(siteId, endpoint)
|
||||
var wg sync.WaitGroup
|
||||
var localWinner string
|
||||
var holepunchViable bool
|
||||
|
||||
if len(localEndpoints) > 0 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
localWinner = pm.peerMonitor.RapidTestLocalEndpoints(siteId, localEndpoints)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
holepunchViable = pm.peerMonitor.RapidTestPeer(siteId, endpoint)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if localWinner != "" {
|
||||
logger.Info("Rapid test: local connection viable for site %d, switching to %s", siteId, localWinner)
|
||||
pm.LocalPeer(siteId, localWinner)
|
||||
return
|
||||
}
|
||||
|
||||
if !holepunchViable {
|
||||
// Holepunch failed rapid test, request relay immediately
|
||||
@@ -926,6 +989,11 @@ func (pm *PeerManager) MarkPeerRelayed(siteID int, relayed bool) {
|
||||
func (pm *PeerManager) UnRelayPeer(siteId int, endpoint string) error {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists && peer.ActiveLocalEndpoint != "" {
|
||||
pm.mu.Unlock()
|
||||
logger.Info("Ignoring unrelay request for site %d: local connection is active", siteId)
|
||||
return nil
|
||||
}
|
||||
if exists {
|
||||
// Store the relay endpoint
|
||||
peer.Endpoint = endpoint
|
||||
@@ -958,6 +1026,75 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LocalPeer switches a peer to a local network endpoint discovered by the peer monitor.
|
||||
// Local endpoints take priority over both the public endpoint and the relay, so this
|
||||
// bypasses relay/public-endpoint bookkeeping entirely and just updates the WireGuard
|
||||
// endpoint directly.
|
||||
func (pm *PeerManager) LocalPeer(siteId int, localEndpoint string) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists {
|
||||
peer.ActiveLocalEndpoint = localEndpoint
|
||||
pm.peers[siteId] = peer
|
||||
}
|
||||
pm.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
logger.Error("Cannot switch to local connection: peer with site ID %d not found", siteId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update only the endpoint for this peer (update_only preserves other settings)
|
||||
wgConfig := fmt.Sprintf(`public_key=%s
|
||||
update_only=true
|
||||
endpoint=%s`, util.FixKey(peer.PublicKey), localEndpoint)
|
||||
|
||||
if err := pm.device.IpcSet(wgConfig); err != nil {
|
||||
logger.Error("Failed to switch peer %d to local connection: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pm.APIServer != nil {
|
||||
pm.APIServer.UpdatePeerLocalStatus(siteId, localEndpoint, true)
|
||||
}
|
||||
|
||||
logger.Info("Switched peer %d to local connection at %s", siteId, localEndpoint)
|
||||
}
|
||||
|
||||
// UnLocalPeer switches a peer away from its active local endpoint back to the public
|
||||
// endpoint, resuming the normal public/relay monitoring logic from scratch (which will
|
||||
// re-trigger relay on its own if the public endpoint also turns out to be unreachable).
|
||||
func (pm *PeerManager) UnLocalPeer(siteId int) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
publicDNS := pm.publicDNS
|
||||
if exists {
|
||||
peer.ActiveLocalEndpoint = ""
|
||||
pm.peers[siteId] = peer
|
||||
}
|
||||
pm.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
logger.Error("Cannot fall back from local connection: peer with site ID %d not found", siteId)
|
||||
return
|
||||
}
|
||||
|
||||
resolved, err := util.ResolveDomainUpstream(formatEndpoint(peer.Endpoint), publicDNS)
|
||||
if err != nil {
|
||||
logger.Error("Failed to resolve fallback endpoint for peer %d: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pm.UnRelayPeer(siteId, resolved); err != nil {
|
||||
logger.Error("Failed to fall back peer %d from local connection: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pm.APIServer != nil {
|
||||
pm.APIServer.UpdatePeerLocalStatus(siteId, resolved, false)
|
||||
}
|
||||
}
|
||||
|
||||
// isBetterConnection returns true if connection quality (a) is better than (b).
|
||||
// Priority: connected > disconnected, then direct > relayed, then lower RTT.
|
||||
func isBetterConnection(aConn bool, aRelay bool, aRTT time.Duration,
|
||||
@@ -1000,6 +1137,49 @@ func (pm *PeerManager) selectBestOwner(claims map[int]bool) int {
|
||||
return bestSiteId
|
||||
}
|
||||
|
||||
// shouldSwitchOwner decides whether ownership of cidr should move from the current
|
||||
// owner to the candidate. It applies hysteresis so two sites with roughly equal
|
||||
// performance don't flap back and forth:
|
||||
// - A switch driven by connectivity class (connected vs not, direct vs relayed) is
|
||||
// always allowed immediately - these are correctness issues, not noise.
|
||||
// - A switch driven purely by RTT requires both a minimum improvement margin and
|
||||
// that the cooldown since the last switch of this route has elapsed.
|
||||
//
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) shouldSwitchOwner(cidr string, currentSiteId, candidateSiteId int) bool {
|
||||
curConn, curRelay, curRTT := pm.peerMonitor.GetConnectionQuality(currentSiteId)
|
||||
candConn, candRelay, candRTT := pm.peerMonitor.GetConnectionQuality(candidateSiteId)
|
||||
|
||||
// Connectivity-class differences (up/down, direct/relayed) are not subject to
|
||||
// hysteresis - always act on them so we don't stay stuck on a broken route.
|
||||
if curConn != candConn || curRelay != candRelay {
|
||||
return true
|
||||
}
|
||||
if !curConn {
|
||||
return false // both down, nothing to do
|
||||
}
|
||||
|
||||
// Same connectivity class: only switch on a meaningful, sustained RTT win.
|
||||
if candRTT == 0 || curRTT == 0 {
|
||||
return false
|
||||
}
|
||||
minImprovement := time.Duration(float64(curRTT) * routeSwitchRTTMargin)
|
||||
if minImprovement < routeSwitchMinAbsMargin {
|
||||
minImprovement = routeSwitchMinAbsMargin
|
||||
}
|
||||
if candRTT > curRTT-minImprovement {
|
||||
return false // not enough of an improvement to be worth switching
|
||||
}
|
||||
|
||||
if lastChange, ok := pm.lastOwnerChange[cidr]; ok {
|
||||
if time.Since(lastChange) < routeSwitchCooldown {
|
||||
return false // switched too recently, avoid flapping
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getWireGuardAllowedIPs returns the full set of IPs that should be in WireGuard
|
||||
// for a peer: server IP /32 plus all shared IPs it currently owns.
|
||||
// Must be called with pm.mu held.
|
||||
@@ -1067,6 +1247,7 @@ func (pm *PeerManager) optimizeRoutes() {
|
||||
if !hasOwner {
|
||||
// No current owner, just assign
|
||||
pm.allowedIPOwners[cidr] = bestOwner
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
if toPeer, exists := pm.peers[bestOwner]; exists {
|
||||
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
|
||||
logger.Error("Failed to assign IP %s to site %d: %v", cidr, bestOwner, err)
|
||||
@@ -1075,10 +1256,16 @@ func (pm *PeerManager) optimizeRoutes() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !pm.shouldSwitchOwner(cidr, currentOwner, bestOwner) {
|
||||
continue // Not a big enough or sustained enough improvement, avoid flapping
|
||||
}
|
||||
|
||||
logger.Info("Route optimizer: moving %s from site %d to site %d", cidr, currentOwner, bestOwner)
|
||||
if err := pm.transferOwnership(cidr, currentOwner, bestOwner); err != nil {
|
||||
logger.Error("Failed to transfer ownership of %s from site %d to site %d: %v",
|
||||
cidr, currentOwner, bestOwner, err)
|
||||
} else {
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+401
-6
@@ -67,6 +67,23 @@ type PeerMonitor struct {
|
||||
holepunchMaxAttempts int // max consecutive failures before triggering relay
|
||||
holepunchFailures map[int]int // siteID -> consecutive failure count
|
||||
|
||||
// Local endpoint testing fields. Local endpoints are ip:port addresses on the
|
||||
// site host's local network interfaces (ordered best-to-worst by the server).
|
||||
// When one is reachable it takes priority over both the public endpoint and
|
||||
// the relay.
|
||||
localEndpoints map[int][]string // siteID -> ordered candidate local endpoints
|
||||
localActiveEndpoint map[int]string // siteID -> currently active local endpoint ("" = not using local)
|
||||
localFailures map[int]int // siteID -> consecutive failures of the active local endpoint
|
||||
localTestTimeout time.Duration // timeout for each local endpoint probe
|
||||
|
||||
// Local connection switch callbacks, set by the PeerManager
|
||||
localSwitchCallback func(siteId int, endpoint string) // invoked when a local endpoint becomes active
|
||||
localFallbackCallback func(siteId int) // invoked when we fall back from a local endpoint
|
||||
|
||||
// Local connection sender tracking, keyed by chainId (informational messages only)
|
||||
localSends map[string]func()
|
||||
localSendMu sync.Mutex
|
||||
|
||||
// Exponential backoff fields for holepunch monitor
|
||||
defaultHolepunchMinInterval time.Duration // Minimum interval (initial)
|
||||
defaultHolepunchMaxInterval time.Duration
|
||||
@@ -118,6 +135,11 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
|
||||
relaySends: make(map[string]func()),
|
||||
holepunchMaxAttempts: 3, // Trigger relay after 3 consecutive failures
|
||||
holepunchFailures: make(map[int]int),
|
||||
localEndpoints: make(map[int][]string),
|
||||
localActiveEndpoint: make(map[int]string),
|
||||
localFailures: make(map[int]int),
|
||||
localTestTimeout: 300 * time.Millisecond, // local network round trips should be fast
|
||||
localSends: make(map[string]func()),
|
||||
// Rapid initial test settings: complete within ~1.5 seconds
|
||||
rapidTestInterval: 200 * time.Millisecond, // 200ms between attempts
|
||||
rapidTestTimeout: 400 * time.Millisecond, // 400ms timeout per attempt
|
||||
@@ -235,7 +257,7 @@ func (pm *PeerMonitor) ResetPeerHolepunchInterval() {
|
||||
}
|
||||
|
||||
// AddPeer adds a new peer to monitor
|
||||
func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint string) error {
|
||||
func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint string, localEndpoints []string) error {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
@@ -253,6 +275,9 @@ func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint st
|
||||
|
||||
pm.holepunchEndpoints[siteID] = holepunchEndpoint
|
||||
pm.holepunchStatus[siteID] = false // Initially unknown/disconnected
|
||||
pm.localEndpoints[siteID] = localEndpoints
|
||||
pm.localActiveEndpoint[siteID] = ""
|
||||
pm.localFailures[siteID] = 0
|
||||
|
||||
if pm.running {
|
||||
if err := client.StartMonitor(func(status ConnectionStatus) {
|
||||
@@ -275,6 +300,25 @@ func (pm *PeerMonitor) UpdateHolepunchEndpoint(siteID int, endpoint string) {
|
||||
logger.Debug("Updated holepunch endpoint for site %d to %s", siteID, endpoint)
|
||||
}
|
||||
|
||||
// UpdateLocalEndpoints updates the candidate local endpoints for a peer
|
||||
func (pm *PeerMonitor) UpdateLocalEndpoints(siteID int, localEndpoints []string) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
pm.localEndpoints[siteID] = localEndpoints
|
||||
logger.Debug("Updated local endpoints for site %d: %v", siteID, localEndpoints)
|
||||
}
|
||||
|
||||
// SetLocalConnectionCallbacks registers the callbacks invoked when a peer switches to
|
||||
// or falls back from a local network endpoint. onLocal is called with the endpoint that
|
||||
// became active; onFallback is called when we give up on the active local endpoint and
|
||||
// resume the normal public/relay monitoring logic.
|
||||
func (pm *PeerMonitor) SetLocalConnectionCallbacks(onLocal func(siteId int, endpoint string), onFallback func(siteId int)) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
pm.localSwitchCallback = onLocal
|
||||
pm.localFallbackCallback = onFallback
|
||||
}
|
||||
|
||||
// RapidTestPeer performs a rapid connectivity test for a newly added peer.
|
||||
// This is designed to quickly determine if holepunch is viable within ~1-2 seconds.
|
||||
// Returns true if the connection is viable (holepunch works), false if it should relay.
|
||||
@@ -326,6 +370,126 @@ func (pm *PeerMonitor) RapidTestPeer(siteID int, endpoint string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RapidTestLocalEndpoints performs a rapid connectivity test of local candidate endpoints
|
||||
// for a newly added peer, so local viability is known within the same ~1-2 second window as
|
||||
// RapidTestPeer's public-endpoint test (rather than waiting for the next backoff-loop tick,
|
||||
// which could be tens of seconds away). Candidates are tried in order (best-to-worst) and
|
||||
// the first reachable one wins. Returns the winning endpoint, or "" if none are reachable.
|
||||
func (pm *PeerMonitor) RapidTestLocalEndpoints(siteID int, endpoints []string) string {
|
||||
if pm.holepunchTester == nil || len(endpoints) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
pm.mutex.Lock()
|
||||
timeout := pm.rapidTestTimeout
|
||||
pm.mutex.Unlock()
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
result := pm.holepunchTester.TestEndpoint(endpoint, timeout)
|
||||
if !result.Success {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("Rapid test: local endpoint %s for site %d SUCCEEDED (RTT: %v)", endpoint, siteID, result.RTT)
|
||||
|
||||
pm.mutex.Lock()
|
||||
// Peer may have been removed while we were testing.
|
||||
stillTracked := false
|
||||
if _, tracked := pm.localEndpoints[siteID]; tracked {
|
||||
stillTracked = true
|
||||
pm.localActiveEndpoint[siteID] = endpoint
|
||||
pm.localFailures[siteID] = 0
|
||||
}
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if stillTracked {
|
||||
pm.sendLocal(siteID, endpoint)
|
||||
}
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
logger.Info("Rapid test: no local endpoint reachable for site %d", siteID)
|
||||
return ""
|
||||
}
|
||||
|
||||
// remainingLocalCandidates returns all of endpoints except exclude, preserving order.
|
||||
func remainingLocalCandidates(endpoints []string, exclude string) []string {
|
||||
remaining := make([]string, 0, len(endpoints))
|
||||
for _, ep := range endpoints {
|
||||
if ep != exclude {
|
||||
remaining = append(remaining, ep)
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
// rapidTestOnLocalFallback runs a fast (~1-2 second) test of the public endpoint, racing it
|
||||
// against any remaining untried local candidates, immediately after we fall back from a dead
|
||||
// active local endpoint. Without this, the peer would sit on the public endpoint - which may
|
||||
// itself be unreachable - relying on the normal checkHolepunchEndpoints loop to notice, which
|
||||
// can take tens of seconds if the holepunch backoff interval had climbed while the local
|
||||
// endpoint was stable. If neither the public endpoint nor a local candidate is reachable, relay
|
||||
// is requested immediately. Mirrors PeerManager.performRapidInitialTest's race, but is triggered
|
||||
// by local-endpoint failure rather than initial peer setup.
|
||||
func (pm *PeerMonitor) rapidTestOnLocalFallback(siteID int, publicEndpoint string, remainingLocal []string) {
|
||||
if pm.holepunchTester == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var localWinner string
|
||||
var holepunchViable bool
|
||||
|
||||
if len(remainingLocal) > 0 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
localWinner = pm.RapidTestLocalEndpoints(siteID, remainingLocal)
|
||||
}()
|
||||
}
|
||||
|
||||
if publicEndpoint != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
holepunchViable = pm.RapidTestPeer(siteID, publicEndpoint)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
pm.mutex.Lock()
|
||||
_, stillTracked := pm.localEndpoints[siteID]
|
||||
noLocalActiveYet := pm.localActiveEndpoint[siteID] == ""
|
||||
switchCb := pm.localSwitchCallback
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if !stillTracked {
|
||||
return // peer was removed while we were testing
|
||||
}
|
||||
|
||||
if localWinner != "" {
|
||||
// RapidTestLocalEndpoints already recorded the new active endpoint and notified the
|
||||
// server, but doesn't move the WireGuard peer itself - do that here, unless a
|
||||
// concurrent checkLocalEndpoints tick already beat us to activating something.
|
||||
if noLocalActiveYet && switchCb != nil {
|
||||
switchCb(siteID, localWinner)
|
||||
}
|
||||
logger.Info("Rapid fallback test: local connection %s viable for site %d", localWinner, siteID)
|
||||
return
|
||||
}
|
||||
|
||||
if !holepunchViable {
|
||||
logger.Warn("Rapid fallback test: site %d unreachable on public endpoint after local fallback, requesting relay", siteID)
|
||||
if pm.wsClient != nil {
|
||||
pm.sendRelay(siteID)
|
||||
}
|
||||
} else {
|
||||
logger.Info("Rapid fallback test: site %d reachable on public endpoint after local fallback", siteID)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerEndpoint updates the monitor endpoint for a peer
|
||||
func (pm *PeerMonitor) UpdatePeerEndpoint(siteID int, monitorPeer string) {
|
||||
pm.mutex.Lock()
|
||||
@@ -359,15 +523,18 @@ func (pm *PeerMonitor) removePeerUnlocked(siteID int) {
|
||||
// RemovePeer stops monitoring a peer and removes it from the monitor
|
||||
func (pm *PeerMonitor) RemovePeer(siteID int) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
// remove the holepunch endpoint info
|
||||
delete(pm.holepunchEndpoints, siteID)
|
||||
delete(pm.holepunchStatus, siteID)
|
||||
delete(pm.relayedPeers, siteID)
|
||||
delete(pm.holepunchFailures, siteID)
|
||||
delete(pm.localEndpoints, siteID)
|
||||
delete(pm.localActiveEndpoint, siteID)
|
||||
delete(pm.localFailures, siteID)
|
||||
|
||||
pm.removePeerUnlocked(siteID)
|
||||
pm.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (pm *PeerMonitor) RemoveHolepunchEndpoint(siteID int) {
|
||||
@@ -412,9 +579,18 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
|
||||
pm.wgConnectionRTT[siteID] = status.RTT
|
||||
}
|
||||
isRelayed := pm.relayedPeers[siteID]
|
||||
localEndpoint := pm.localActiveEndpoint[siteID]
|
||||
endpoint := pm.holepunchEndpoints[siteID]
|
||||
pm.mutex.Unlock()
|
||||
|
||||
isLocal := localEndpoint != ""
|
||||
if isLocal {
|
||||
// Report the active local endpoint rather than the public one; local and relay
|
||||
// are mutually exclusive.
|
||||
endpoint = localEndpoint
|
||||
isRelayed = false
|
||||
}
|
||||
|
||||
// Log status changes
|
||||
if !exists || previousStatus != status.Connected {
|
||||
if status.Connected {
|
||||
@@ -426,7 +602,7 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
|
||||
|
||||
// Update API with connection status
|
||||
if pm.apiServer != nil {
|
||||
pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed)
|
||||
pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed, isLocal)
|
||||
}
|
||||
|
||||
// Notify route optimizer of status change
|
||||
@@ -481,6 +657,75 @@ func (pm *PeerMonitor) sendUnRelay(siteID int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendLocal notifies the server that this peer switched to a local network endpoint, with
|
||||
// retry keyed by chainId. This is informational (e.g. so the server can relay the information
|
||||
// to newt) - olm does not wait for an acknowledgement before using the local connection, but
|
||||
// it does stop retrying once the server acks via CancelLocalSend, same as relay/unrelay.
|
||||
func (pm *PeerMonitor) sendLocal(siteID int, endpoint string) {
|
||||
if pm.wsClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
chainId := generateChainId()
|
||||
stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/local", map[string]interface{}{
|
||||
"siteId": siteID,
|
||||
"endpoint": endpoint,
|
||||
"chainId": chainId,
|
||||
}, 2*time.Second, 10)
|
||||
|
||||
pm.localSendMu.Lock()
|
||||
pm.localSends[chainId] = stopFunc
|
||||
pm.localSendMu.Unlock()
|
||||
|
||||
logger.Info("Sent local-connection message for site %d (%s, chain %s)", siteID, endpoint, chainId)
|
||||
}
|
||||
|
||||
// sendUnLocal notifies the server that this peer fell back from its local network endpoint,
|
||||
// with retry keyed by chainId.
|
||||
func (pm *PeerMonitor) sendUnLocal(siteID int) {
|
||||
if pm.wsClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
chainId := generateChainId()
|
||||
stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/unlocal", map[string]interface{}{
|
||||
"siteId": siteID,
|
||||
"chainId": chainId,
|
||||
}, 2*time.Second, 10)
|
||||
|
||||
pm.localSendMu.Lock()
|
||||
pm.localSends[chainId] = stopFunc
|
||||
pm.localSendMu.Unlock()
|
||||
|
||||
logger.Info("Sent unlocal-connection message for site %d (chain %s)", siteID, chainId)
|
||||
}
|
||||
|
||||
// CancelLocalSend stops the interval sender for the given chainId, if one exists.
|
||||
// If chainId is empty, all active local-connection senders are stopped.
|
||||
func (pm *PeerMonitor) CancelLocalSend(chainId string) {
|
||||
pm.localSendMu.Lock()
|
||||
defer pm.localSendMu.Unlock()
|
||||
|
||||
if chainId == "" {
|
||||
for id, stop := range pm.localSends {
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
delete(pm.localSends, id)
|
||||
}
|
||||
logger.Info("Cancelled all local-connection senders")
|
||||
return
|
||||
}
|
||||
|
||||
if stop, ok := pm.localSends[chainId]; ok {
|
||||
stop()
|
||||
delete(pm.localSends, chainId)
|
||||
logger.Info("Cancelled local-connection sender for chain %s", chainId)
|
||||
} else {
|
||||
logger.Warn("CancelLocalSend: no active sender for chain %s", chainId)
|
||||
}
|
||||
}
|
||||
|
||||
// CancelRelaySend stops the interval sender for the given chainId, if one exists.
|
||||
// If chainId is empty, all active relay senders are stopped.
|
||||
func (pm *PeerMonitor) CancelRelaySend(chainId string) {
|
||||
@@ -628,7 +873,8 @@ func (pm *PeerMonitor) runHolepunchMonitor() {
|
||||
timer.Reset(currentInterval)
|
||||
logger.Debug("Holepunch monitor interval updated, reset to %v", currentInterval)
|
||||
case <-timer.C:
|
||||
anyStatusChanged := pm.checkHolepunchEndpoints()
|
||||
localChanged := pm.checkLocalEndpoints()
|
||||
anyStatusChanged := pm.checkHolepunchEndpoints() || localChanged
|
||||
|
||||
pm.mutex.Lock()
|
||||
if anyStatusChanged {
|
||||
@@ -650,6 +896,140 @@ func (pm *PeerMonitor) runHolepunchMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
// checkLocalEndpoints tests local network endpoints for sites that have them configured.
|
||||
// For a site not currently using a local endpoint, it probes each candidate in order
|
||||
// (candidates are ordered best-to-worst by the server) and switches to the first one that
|
||||
// succeeds. For a site already using a local endpoint, it re-tests that endpoint and falls
|
||||
// back to the normal public/relay logic after a few consecutive failures.
|
||||
// Returns true if any site's local-connection status changed.
|
||||
func (pm *PeerMonitor) checkLocalEndpoints() bool {
|
||||
pm.mutex.Lock()
|
||||
if !pm.running {
|
||||
pm.mutex.Unlock()
|
||||
return false
|
||||
}
|
||||
if pm.holepunchTester == nil {
|
||||
pm.mutex.Unlock()
|
||||
return false
|
||||
}
|
||||
candidates := make(map[int][]string, len(pm.localEndpoints))
|
||||
for siteID, eps := range pm.localEndpoints {
|
||||
if len(eps) > 0 {
|
||||
candidates[siteID] = eps
|
||||
}
|
||||
}
|
||||
active := make(map[int]string, len(pm.localActiveEndpoint))
|
||||
for siteID, ep := range pm.localActiveEndpoint {
|
||||
active[siteID] = ep
|
||||
}
|
||||
timeout := pm.localTestTimeout
|
||||
maxAttempts := pm.holepunchMaxAttempts
|
||||
pm.mutex.Unlock()
|
||||
|
||||
anyChanged := false
|
||||
|
||||
for siteID, endpoints := range candidates {
|
||||
if activeEndpoint := active[siteID]; activeEndpoint != "" {
|
||||
// Already using a local endpoint - verify it's still working.
|
||||
result := pm.holepunchTester.TestEndpoint(activeEndpoint, timeout)
|
||||
|
||||
pm.mutex.Lock()
|
||||
if _, stillTracked := pm.localEndpoints[siteID]; !stillTracked {
|
||||
pm.mutex.Unlock()
|
||||
continue // peer was removed while we were testing
|
||||
}
|
||||
if result.Success {
|
||||
pm.localFailures[siteID] = 0
|
||||
pm.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
pm.localFailures[siteID]++
|
||||
failureCount := pm.localFailures[siteID]
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if failureCount >= maxAttempts {
|
||||
logger.Warn("Local endpoint %s for site %d failed %d times, falling back to public/relay logic", activeEndpoint, siteID, failureCount)
|
||||
|
||||
pm.mutex.Lock()
|
||||
pm.localActiveEndpoint[siteID] = ""
|
||||
pm.localFailures[siteID] = 0
|
||||
pm.holepunchFailures[siteID] = 0 // don't immediately re-trigger relay on stale failures
|
||||
// The holepunch backoff timer keeps climbing while a local endpoint is
|
||||
// active (checkHolepunchEndpoints skips those sites but backoff still
|
||||
// applies), so reset it here to avoid the resumed public/relay logic
|
||||
// being stuck polling at a stale, heavily-backed-off interval.
|
||||
pm.holepunchCurrentInterval = pm.holepunchMinInterval
|
||||
publicEndpoint := pm.holepunchEndpoints[siteID]
|
||||
remainingLocal := remainingLocalCandidates(pm.localEndpoints[siteID], activeEndpoint)
|
||||
pm.mutex.Unlock()
|
||||
|
||||
anyChanged = true
|
||||
pm.deactivateLocalEndpoint(siteID)
|
||||
|
||||
// Don't wait out the next backed-off checkHolepunchEndpoints tick to find out
|
||||
// whether the public endpoint is reachable - rapidly test it (and any untried
|
||||
// local candidates) now so a total connectivity loss triggers relay within
|
||||
// ~1-2 seconds instead of potentially tens of seconds.
|
||||
go pm.rapidTestOnLocalFallback(siteID, publicEndpoint, remainingLocal)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Not currently using a local endpoint - probe candidates in order.
|
||||
for _, endpoint := range endpoints {
|
||||
result := pm.holepunchTester.TestEndpoint(endpoint, timeout)
|
||||
|
||||
pm.mutex.Lock()
|
||||
if _, stillTracked := pm.localEndpoints[siteID]; !stillTracked {
|
||||
pm.mutex.Unlock()
|
||||
break // peer was removed while we were testing
|
||||
}
|
||||
if !result.Success {
|
||||
pm.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
pm.localActiveEndpoint[siteID] = endpoint
|
||||
pm.localFailures[siteID] = 0
|
||||
pm.mutex.Unlock()
|
||||
|
||||
logger.Info("Local endpoint %s for site %d is reachable (RTT: %v), switching to local connection", endpoint, siteID, result.RTT)
|
||||
anyChanged = true
|
||||
pm.activateLocalEndpoint(siteID, endpoint)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return anyChanged
|
||||
}
|
||||
|
||||
// activateLocalEndpoint invokes the switch callback and notifies the server that a local
|
||||
// endpoint became active for the given site.
|
||||
func (pm *PeerMonitor) activateLocalEndpoint(siteID int, endpoint string) {
|
||||
pm.mutex.Lock()
|
||||
cb := pm.localSwitchCallback
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(siteID, endpoint)
|
||||
}
|
||||
|
||||
pm.sendLocal(siteID, endpoint)
|
||||
}
|
||||
|
||||
// deactivateLocalEndpoint invokes the fallback callback and notifies the server that the
|
||||
// given site fell back from its local endpoint.
|
||||
func (pm *PeerMonitor) deactivateLocalEndpoint(siteID int) {
|
||||
pm.mutex.Lock()
|
||||
cb := pm.localFallbackCallback
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(siteID)
|
||||
}
|
||||
|
||||
pm.sendUnLocal(siteID)
|
||||
}
|
||||
|
||||
// checkHolepunchEndpoints tests all holepunch endpoints
|
||||
// Returns true if any endpoint's status changed
|
||||
func (pm *PeerMonitor) checkHolepunchEndpoints() bool {
|
||||
@@ -661,6 +1041,9 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() bool {
|
||||
}
|
||||
endpoints := make(map[int]string, len(pm.holepunchEndpoints))
|
||||
for siteID, endpoint := range pm.holepunchEndpoints {
|
||||
if pm.localActiveEndpoint[siteID] != "" {
|
||||
continue // using a local connection, skip public/relay monitoring
|
||||
}
|
||||
endpoints[siteID] = endpoint
|
||||
}
|
||||
timeout := pm.holepunchTimeout
|
||||
@@ -718,8 +1101,10 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() bool {
|
||||
wgConnected := pm.wgConnectionStatus[siteID]
|
||||
pm.mutex.Unlock()
|
||||
|
||||
// Update API - use holepunch endpoint and relay status
|
||||
pm.apiServer.UpdatePeerStatus(siteID, wgConnected, result.RTT, endpoint, isRelayed)
|
||||
// Update API - use holepunch endpoint and relay status. Sites with an active
|
||||
// local endpoint are filtered out of this loop above, so isLocal is always
|
||||
// false here.
|
||||
pm.apiServer.UpdatePeerStatus(siteID, wgConnected, result.RTT, endpoint, isRelayed, false)
|
||||
}
|
||||
|
||||
// Handle relay logic based on holepunch status
|
||||
@@ -777,6 +1162,16 @@ func (pm *PeerMonitor) Close() {
|
||||
}
|
||||
pm.relaySendMu.Unlock()
|
||||
|
||||
// Stop all pending local-connection senders
|
||||
pm.localSendMu.Lock()
|
||||
for chainId, stop := range pm.localSends {
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
delete(pm.localSends, chainId)
|
||||
}
|
||||
pm.localSendMu.Unlock()
|
||||
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
|
||||
+19
-11
@@ -10,17 +10,26 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// ConfigurePeer sets up or updates a peer within the WireGuard device
|
||||
// ConfigurePeer sets up or updates a peer within the WireGuard device.
|
||||
// If siteConfig.ActiveLocalEndpoint is set, it takes priority over both the relay and the
|
||||
// public endpoint since it's a directly-reachable address on the site host's local network.
|
||||
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int, publicDNS []string) error {
|
||||
var endpoint string
|
||||
if relay && siteConfig.RelayEndpoint != "" {
|
||||
endpoint = formatEndpoint(siteConfig.RelayEndpoint)
|
||||
var siteHost string
|
||||
if siteConfig.ActiveLocalEndpoint != "" {
|
||||
// Local endpoints are already literal ip:port pairs on the local network, no DNS resolution needed.
|
||||
siteHost = siteConfig.ActiveLocalEndpoint
|
||||
} else {
|
||||
endpoint = formatEndpoint(siteConfig.Endpoint)
|
||||
}
|
||||
siteHost, err := util.ResolveDomainUpstream(endpoint, publicDNS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err)
|
||||
var endpoint string
|
||||
if relay && siteConfig.RelayEndpoint != "" {
|
||||
endpoint = formatEndpoint(siteConfig.RelayEndpoint)
|
||||
} else {
|
||||
endpoint = formatEndpoint(siteConfig.Endpoint)
|
||||
}
|
||||
var err error
|
||||
siteHost, err = util.ResolveDomainUpstream(endpoint, publicDNS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Split off the CIDR of the server IP which is just a string and add /32 for the allowed IP
|
||||
@@ -66,8 +75,7 @@ func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes
|
||||
config := configBuilder.String()
|
||||
logger.Debug("Configuring peer with config: %s", config)
|
||||
|
||||
err = dev.IpcSet(config)
|
||||
if err != nil {
|
||||
if err := dev.IpcSet(config); err != nil {
|
||||
return fmt.Errorf("failed to configure WireGuard peer: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+22
-10
@@ -8,16 +8,21 @@ type PeerAction struct {
|
||||
|
||||
// UpdatePeerData represents the data needed to update a peer
|
||||
type SiteConfig struct {
|
||||
SiteId int `json:"siteId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
RelayEndpoint string `json:"relayEndpoint,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
ServerIP string `json:"serverIP,omitempty"`
|
||||
ServerPort uint16 `json:"serverPort,omitempty"`
|
||||
RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access
|
||||
AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer
|
||||
Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations
|
||||
SiteId int `json:"siteId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
LocalEndpoints []string `json:"localEndpoints,omitempty"` // optional, ip:port endpoints on the site host's local network interfaces, ordered best-to-worst
|
||||
RelayEndpoint string `json:"relayEndpoint,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
ServerIP string `json:"serverIP,omitempty"`
|
||||
ServerPort uint16 `json:"serverPort,omitempty"`
|
||||
RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access
|
||||
AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer
|
||||
Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations
|
||||
|
||||
// ActiveLocalEndpoint tracks the local network endpoint currently in use for this
|
||||
// peer, if any. Not part of the wire protocol; set internally by the PeerManager.
|
||||
ActiveLocalEndpoint string `json:"-"`
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
@@ -41,6 +46,13 @@ type UnRelayPeerData struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// LocalPeerAckData represents the server's acknowledgement of an "olm/wg/local" or
|
||||
// "olm/wg/unlocal" message. olm has already applied the local connection switch by the time
|
||||
// it sends the notification, so the ack is only used to stop the retry sender.
|
||||
type LocalPeerAckData struct {
|
||||
SiteId int `json:"siteId"`
|
||||
}
|
||||
|
||||
// PeerAdd represents the data needed to add remote subnets to a peer
|
||||
type PeerAdd struct {
|
||||
SiteId int `json:"siteId"`
|
||||
|
||||
+53
-1
@@ -22,6 +22,14 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// writeDeadline bounds how long a websocket write may block before it is
|
||||
// treated as a failure. Without this, a write to a TCP connection whose
|
||||
// underlying network interface has disappeared (e.g. laptop sleep/resume,
|
||||
// Wi-Fi roam) can sit buffered in the kernel for minutes without erroring,
|
||||
// which prevents the ping monitor from ever detecting the dead connection
|
||||
// and reconnecting.
|
||||
const writeDeadline = 10 * time.Second
|
||||
|
||||
// AuthError represents an authentication/authorization error (401/403)
|
||||
type AuthError struct {
|
||||
StatusCode int
|
||||
@@ -83,6 +91,7 @@ type Client struct {
|
||||
isDisconnected bool // Flag to track if client is intentionally disconnected
|
||||
reconnectMux sync.RWMutex
|
||||
pingInterval time.Duration
|
||||
pongWait time.Duration // read deadline window; if no pong/message arrives within it, the connection is considered dead
|
||||
onConnect func() error
|
||||
onTokenUpdate func(token string, exitNodes []ExitNode)
|
||||
onAuthError func(statusCode int, message string) // Callback for auth errors
|
||||
@@ -167,6 +176,16 @@ func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.
|
||||
OrgID: orgId,
|
||||
}
|
||||
|
||||
// Read deadline window: must exceed pingInterval so a healthy connection
|
||||
// (which gets a pong/message at least every pingInterval) is never torn
|
||||
// down, but a dead/half-open one — including one where writes keep
|
||||
// "succeeding" because small pings fit in the kernel send buffer even
|
||||
// under total packet loss — is detected within ~2 ping cycles.
|
||||
pongWait := pingInterval * 2
|
||||
if pongWait < 20*time.Second {
|
||||
pongWait = 20 * time.Second
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: config,
|
||||
baseURL: endpoint, // default value
|
||||
@@ -175,6 +194,7 @@ func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.
|
||||
reconnectInterval: 3 * time.Second,
|
||||
isConnected: false,
|
||||
pingInterval: pingInterval,
|
||||
pongWait: pongWait,
|
||||
clientType: "olm",
|
||||
pingDone: make(chan struct{}),
|
||||
}
|
||||
@@ -268,6 +288,9 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
|
||||
|
||||
c.writeMux.Lock()
|
||||
defer c.writeMux.Unlock()
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
@@ -582,6 +605,18 @@ func (c *Client) establishConnection() error {
|
||||
c.conn = conn
|
||||
c.setConnected(true)
|
||||
|
||||
// Arm a read deadline and refresh it whenever a pong arrives. Combined with
|
||||
// the protocol-level ping sent alongside the app-level one in sendPing,
|
||||
// this detects a dead or half-open connection (e.g. the route disappearing
|
||||
// on sleep/resume, or total packet loss) that a write-side check alone
|
||||
// misses: small periodic pings fit in the kernel send buffer and keep
|
||||
// "succeeding" even when nothing is actually reaching the peer.
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
c.conn.SetPongHandler(func(appData string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Note: ping monitor is NOT started here - it will be started when
|
||||
// StartPingMonitor() is called after registration completes
|
||||
|
||||
@@ -697,7 +732,17 @@ func (c *Client) sendPing() {
|
||||
logger.Debug("websocket: Sending ping: %+v", pingMsg)
|
||||
|
||||
c.writeMux.Lock()
|
||||
err := c.conn.WriteJSON(pingMsg)
|
||||
err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err == nil {
|
||||
err = c.conn.WriteJSON(pingMsg)
|
||||
}
|
||||
if err == nil {
|
||||
// Protocol-level ping: a standards-compliant server replies with a
|
||||
// PONG, which refreshes the read deadline via SetPongHandler. This is
|
||||
// what actually detects a half-open connection where writes still
|
||||
// "succeed" (buffered by the kernel) but nothing is reaching the peer.
|
||||
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline))
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
@@ -803,6 +848,13 @@ func (c *Client) readPumpWithDisconnectDetection() {
|
||||
return
|
||||
default:
|
||||
messageType, p, err := c.conn.ReadMessage()
|
||||
if err == nil {
|
||||
// Any inbound traffic means the peer is alive — extend the
|
||||
// read deadline (also covers servers that answer the
|
||||
// app-level "olm/ping" with a message rather than a
|
||||
// protocol pong).
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
}
|
||||
if err != nil {
|
||||
// Check if we're shutting down or explicitly disconnected before logging error
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user