Compare commits

...

23 Commits

Author SHA1 Message Date
Owen Schwartz
403e14327d Merge pull request #125 from fosrl/dev
1.6.0
2026-06-10 11:19:52 -07:00
Owen
31f6d699a8 Add dns watchdog and override fixer 2026-06-09 21:17:13 -07:00
Owen
6e41eb99ab Merge branch 'main' into dev 2026-06-09 12:03:59 -07:00
Owen
12c1918e26 Include chainId for warning the user with an error 2026-06-09 12:03:50 -07:00
Owen
0d6503c03f Add comment 2026-06-09 12:03:50 -07:00
miloschwartz
b789c9af75 update issue template 2026-05-25 21:41:23 -07:00
Owen Schwartz
8cc854e313 Merge pull request #121 from fosrl/dev
Support JIT sync message
2026-05-13 15:19:06 -07:00
Owen
2cd16e24a1 Handle jit peers with alias 2026-05-13 15:09:45 -07:00
Owen Schwartz
b6b35b4581 Merge pull request #120 from LaurenceJJones/investigate/106-handleWgPeerUpdate-alias-merge
fix(peers): merge Aliases in handleWgPeerUpdate
2026-05-11 10:12:56 -07:00
Laurence
3f64336b0b peers: merge Aliases in handleWgPeerUpdate
WireGuard update-peer messages now copy Aliases from the payload into
the merged SiteConfig so UpdatePeer can refresh alias DNS records.

Fixes fosrl/olm#106

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:45:08 +01:00
Owen Schwartz
e94c13f601 Merge pull request #119 from fosrl/dev
Guard add peer with is registered`
2026-05-06 22:08:22 -07:00
Owen
fff806c53d Guard add peer with is registered` 2026-05-06 22:06:03 -07:00
Owen Schwartz
84d7e8d926 Merge pull request #118 from fosrl/dev
Update newt
2026-04-27 20:13:48 -07:00
Owen
fde70dd15b Update newt 2026-04-27 20:13:20 -07:00
Owen
7bf6da1729 Update newt 2026-04-22 12:22:39 -07:00
Owen Schwartz
9f68f171ba Merge pull request #117 from fosrl/dev
1.5.0
2026-04-22 12:13:09 -07:00
Owen
7a8f4ab049 Merge branch 'main' into dev 2026-04-22 12:09:50 -07:00
Owen
334ea156b6 Merge branch 'private-site-ha' into dev 2026-04-21 15:08:11 -07:00
Owen
aa838fec61 Mention the cli 2026-04-19 15:49:08 -07:00
Owen
6eaf8c1475 Basic route selector working 2026-04-13 18:24:11 -07:00
Owen
5ef6b21a6e Add CODEOWNERS 2026-04-07 11:34:38 -04:00
Owen
7d83518951 Get peer manager 2026-03-20 17:28:39 -07:00
Owen
964532777a Increase attempts 2026-03-19 17:24:41 -07:00
25 changed files with 1039 additions and 104 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

@@ -14,12 +14,13 @@ body:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version: (e.g., Ubuntu 22.04)
- OS Type & Version:
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Olm Version: (if applicable)
- Client Version:
validations:
required: true

View File

@@ -1,4 +1,5 @@
# Olm
Olm is being phased out in favor of the [Pangolin CLI](https://github.com/fosrl/cli) and is only meant for advanced use cases.
Olm is a [WireGuard](https://www.wireguard.com/) tunnel client designed to securely connect your computer to Newt sites running on remote networks.

View File

@@ -20,3 +20,9 @@ func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on Android.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

View File

@@ -15,6 +15,13 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on macOS
// Uses scutil for DNS configuration
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
// Defensively clear any stale DNS state from a previous unclean shutdown
// before installing the new override. This makes a second tunnel start
// safe even if the previous client crashed without restoring DNS.
if err := CleanupStaleState(interfaceName); err != nil {
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
}
var err error
configurator, err = platform.NewDarwinDNSConfigurator()
if err != nil {
@@ -78,3 +85,31 @@ func CleanupStaleState(interfaceName string) error {
logger.Info("Stale DNS state cleanup completed successfully")
return nil
}
// ForceResetDNS forcibly clears any DNS override state, whether or not the
// current process installed it. This is intended for the "reset-dns" CLI
// command and for the watchdog process to recover from a stuck override
// left behind by a crashed client.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Darwin (interface=%s)", interfaceName)
// First clean up any persisted state from a previous session.
cleanupErr := CleanupStaleState(interfaceName)
// Then, if the current process happens to hold a live configurator,
// instruct it to restore DNS as well so in-memory state is consistent.
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
// As a last-resort defense, sweep any scutil keys matching our naming
// convention even if no state file exists.
if err := platform.SweepOlmScutilKeys(); err != nil {
logger.Warn("ForceResetDNS: scutil sweep failed: %v", err)
}
return cleanupErr
}

View File

@@ -19,3 +19,9 @@ func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on iOS.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

View File

@@ -15,6 +15,13 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on Linux/FreeBSD
// Detects the DNS manager by reading /etc/resolv.conf and verifying runtime availability
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
// Defensively clear any stale DNS state from a previous unclean shutdown
// before installing the new override. This makes a second tunnel start
// safe even if the previous client crashed without restoring DNS.
if err := CleanupStaleState(interfaceName); err != nil {
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
}
var err error
// Detect which DNS manager is in use by checking /etc/resolv.conf and runtime availability
@@ -144,3 +151,25 @@ func CleanupStaleState(interfaceName string) error {
logger.Info("Stale DNS state cleanup completed successfully")
return nil
}
// ForceResetDNS forcibly clears any DNS override state, whether or not the
// current process installed it. This is intended for the "reset-dns" CLI
// command and for the watchdog process to recover from a stuck override
// left behind by a crashed client.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Linux/FreeBSD (interface=%s)", interfaceName)
// First clean up any persisted state from a previous session.
cleanupErr := CleanupStaleState(interfaceName)
// Then, if the current process happens to hold a live configurator,
// instruct it to restore DNS as well so in-memory state is consistent.
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
return cleanupErr
}

View File

@@ -15,6 +15,12 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on Windows
// Uses registry-based configuration (automatically extracts interface GUID)
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
// Defensively clear any stale DNS state from a previous unclean shutdown
// before installing the new override.
if err := CleanupStaleState(interfaceName); err != nil {
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
}
var err error
configurator, err = platform.NewWindowsDNSConfigurator(interfaceName)
if err != nil {
@@ -77,3 +83,17 @@ func CleanupStaleState(interfaceName string) error {
logger.Debug("Windows DNS cleanup: no stale state to clean (interface-specific)")
return nil
}
// ForceResetDNS forcibly clears any DNS override state. On Windows this is
// largely a no-op because the registry override is tied to the interface
// GUID and is reclaimed when the interface is torn down.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Windows (interface=%s)", interfaceName)
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
return CleanupStaleState(interfaceName)
}

136
dns/override/watchdog.go Normal file
View File

@@ -0,0 +1,136 @@
package olm
import (
"context"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/fosrl/newt/logger"
)
// WatchdogConfig configures the DNS override watchdog. The watchdog runs as
// an external process (spawned via SpawnWatchdog) and monitors a parent olm
// process. When the parent appears to have died without restoring DNS, the
// watchdog forcibly resets the system DNS configuration.
type WatchdogConfig struct {
// ParentPID is the PID of the olm process that installed the DNS
// override. The watchdog exits when this PID is no longer alive.
ParentPID int
// SocketPath is the path to the olm Unix domain socket (or named pipe
// on Windows). The watchdog uses it as a secondary liveness signal.
// May be empty if no socket-based API is enabled.
SocketPath string
// InterfaceName is the name of the WireGuard interface whose DNS
// override should be reset on parent death.
InterfaceName string
// CheckInterval is how often to poll the parent's liveness.
// Defaults to 5 seconds when zero.
CheckInterval time.Duration
// FailureThreshold is the number of consecutive failed liveness checks
// before the watchdog declares the parent dead and resets DNS.
// Defaults to 3 when zero.
FailureThreshold int
}
// RunWatchdog runs the watchdog loop in the current process until either
// (a) the parent dies and DNS is reset, or (b) ctx is cancelled.
func RunWatchdog(ctx context.Context, cfg WatchdogConfig) error {
if cfg.ParentPID <= 0 {
return fmt.Errorf("watchdog: invalid parent PID %d", cfg.ParentPID)
}
if cfg.CheckInterval <= 0 {
cfg.CheckInterval = 5 * time.Second
}
if cfg.FailureThreshold <= 0 {
cfg.FailureThreshold = 3
}
logger.Info("DNS watchdog started: parent=%d interval=%s threshold=%d socket=%q interface=%q",
cfg.ParentPID, cfg.CheckInterval, cfg.FailureThreshold, cfg.SocketPath, cfg.InterfaceName)
ticker := time.NewTicker(cfg.CheckInterval)
defer ticker.Stop()
consecutiveFailures := 0
for {
select {
case <-ctx.Done():
logger.Info("DNS watchdog context cancelled, exiting cleanly")
return ctx.Err()
case <-ticker.C:
}
alive := isParentAlive(cfg.ParentPID, cfg.SocketPath)
if alive {
if consecutiveFailures > 0 {
logger.Debug("DNS watchdog: parent recovered after %d failures", consecutiveFailures)
}
consecutiveFailures = 0
continue
}
consecutiveFailures++
logger.Warn("DNS watchdog: parent liveness check failed (%d/%d)",
consecutiveFailures, cfg.FailureThreshold)
if consecutiveFailures >= cfg.FailureThreshold {
logger.Warn("DNS watchdog: parent declared dead, forcing DNS reset")
if err := ForceResetDNS(cfg.InterfaceName); err != nil {
logger.Error("DNS watchdog: ForceResetDNS failed: %v", err)
return err
}
logger.Info("DNS watchdog: DNS reset complete, exiting")
return nil
}
}
}
// isParentAlive returns true if the parent process appears to be alive. It
// considers the parent alive if EITHER the PID is still running OR the
// socket-based health endpoint responds. This dual check avoids false
// positives where one signal is flaky (e.g., socket blocked but process
// still recovering).
func isParentAlive(pid int, socketPath string) bool {
if pidAlive(pid) {
return true
}
// Process is gone; double-check via socket to avoid races where PID
// recycling or signal-0 quirks lie to us. Socket should already be
// gone too.
if socketPath != "" && socketHealthy(socketPath) {
return true
}
return false
}
// socketHealthy attempts a fast /health request over the unix socket.
func socketHealthy(socketPath string) bool {
if _, err := os.Stat(socketPath); err != nil {
return false
}
client := &http.Client{
Timeout: 2 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 2 * time.Second}
return d.DialContext(ctx, "unix", socketPath)
},
},
}
resp, err := client.Get("http://localhost/health")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

View File

@@ -0,0 +1,119 @@
//go:build !windows
package olm
import (
"fmt"
"os"
"os/exec"
"strconv"
"syscall"
"github.com/fosrl/newt/logger"
)
// SpawnWatchdogConfig captures the inputs needed to launch the external
// watchdog subprocess that monitors the calling olm process and forces a
// DNS reset if the parent dies before restoring DNS.
type SpawnWatchdogConfig struct {
// Executable is the path to the binary that will host the watchdog
// (typically os.Executable()). The binary must understand the
// watchdog subcommand layout described below.
Executable string
// Subcommand is the argv prefix the binary uses to enter watchdog
// mode (e.g., []string{"watchdog"} or []string{"dns", "watchdog"}).
Subcommand []string
// InterfaceName is the WireGuard interface whose DNS override should
// be reset if the parent dies.
InterfaceName string
// SocketPath is the parent's olm API socket path (may be empty).
SocketPath string
// LogFile, if non-empty, is the path the watchdog writes its stdout
// and stderr to. If empty, /dev/null is used.
LogFile string
}
// SpawnWatchdog launches the watchdog subprocess in a detached process group
// so that it survives the death of the parent. The returned *exec.Cmd is the
// handle the parent should call StopWatchdog on during clean shutdown.
//
// The spawned process is invoked as:
//
// <Executable> <Subcommand...> --parent-pid=<ppid> \
// --interface=<InterfaceName> [--socket=<SocketPath>]
//
// Both pangolin (cli) and olm should map their watchdog subcommand to
// RunWatchdog.
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
if cfg.Executable == "" {
return nil, fmt.Errorf("watchdog: executable is required")
}
if len(cfg.Subcommand) == 0 {
return nil, fmt.Errorf("watchdog: subcommand is required")
}
args := append([]string{}, cfg.Subcommand...)
args = append(args,
"--parent-pid="+strconv.Itoa(os.Getpid()),
"--interface="+cfg.InterfaceName,
)
if cfg.SocketPath != "" {
args = append(args, "--socket="+cfg.SocketPath)
}
cmd := exec.Command(cfg.Executable, args...)
// Detach: new session so the watchdog is not killed by a signal
// delivered to the parent's process group.
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
// Direct watchdog output to a log file or /dev/null so it doesn't
// share file descriptors with the parent's TTY.
logTarget := cfg.LogFile
if logTarget == "" {
logTarget = os.DevNull
}
logFile, err := os.OpenFile(logTarget, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, fmt.Errorf("watchdog: open log file: %w", err)
}
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return nil, fmt.Errorf("watchdog: start: %w", err)
}
// We don't need our handle on the log file after the subprocess
// inherits it.
_ = logFile.Close()
logger.Info("DNS watchdog spawned (pid=%d, exe=%s)", cmd.Process.Pid, cfg.Executable)
return cmd, nil
}
// StopWatchdog asks the watchdog to exit cleanly via SIGTERM and reaps it.
// Safe to call with a nil cmd.
func StopWatchdog(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
pid := cmd.Process.Pid
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
logger.Debug("DNS watchdog stop signal failed (pid=%d): %v", pid, err)
}
// Reap in the background; we don't want to block shutdown if the
// watchdog is wedged.
go func() {
_ = cmd.Wait()
logger.Debug("DNS watchdog (pid=%d) reaped", pid)
}()
}

View File

@@ -0,0 +1,29 @@
//go:build windows
package olm
import (
"os/exec"
)
// SpawnWatchdogConfig is provided on Windows for API symmetry but the
// watchdog itself is effectively a no-op there (see watchdog_windows.go).
type SpawnWatchdogConfig struct {
Executable string
Subcommand []string
InterfaceName string
SocketPath string
LogFile string
}
// SpawnWatchdog is a no-op on Windows; DNS overrides are interface-GUID
// scoped and reclaimed when the interface is removed.
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
_ = cfg
return nil, nil
}
// StopWatchdog is a no-op on Windows.
func StopWatchdog(cmd *exec.Cmd) {
_ = cmd
}

View File

@@ -0,0 +1,25 @@
//go:build !windows
package olm
import (
"os"
"syscall"
)
// pidAlive returns true if the process with the given PID is still alive.
// On Unix-like systems we use signal 0, which performs error checking but
// does not deliver an actual signal.
func pidAlive(pid int) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
return false
}
return true
}

View File

@@ -0,0 +1,15 @@
//go:build windows
package olm
// pidAlive on Windows. Reliable PID probing on Windows requires syscall
// OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION followed by
// GetExitCodeProcess, which is non-trivial. Since DNS override on Windows
// is interface-GUID-scoped and is naturally cleaned up when the WireGuard
// interface goes away, the watchdog is effectively a no-op on Windows.
// We always report the parent as alive so the watchdog never tears down
// DNS based on PID checks.
func pidAlive(pid int) bool {
_ = pid
return true
}

View File

@@ -422,6 +422,17 @@ func (d *DarwinDNSConfigurator) clearState() error {
// configurator from a previous unclean shutdown. This is a static function that can be
// called without creating a configurator instance, useful for cleanup before network operations.
func CleanupStaleDarwinDNS() error {
// Always sweep orphaned Olm scutil keys regardless of whether a state
// file exists. This protects against cases where the state file was
// lost (e.g., user home wiped, write failed) but DNS keys are still
// installed in the running scutil session.
defer func() {
_ = SweepOlmScutilKeys()
// Flush DNS cache after any sweep so changes take effect.
_ = exec.Command(dscacheutilPath, "-flushcache").Run()
_ = exec.Command("killall", "-HUP", "mDNSResponder").Run()
}()
stateFilePath := getDNSStateFilePath()
// Check if state file exists
@@ -473,3 +484,59 @@ func CleanupStaleDarwinDNS() error {
return nil
}
// SweepOlmScutilKeys enumerates scutil State:/Network/Service/Olm-* keys and
// removes any that are present. This is a best-effort safety net used when
// state files have been lost or never written.
func SweepOlmScutilKeys() error {
// list scutil keys matching our naming convention
listOutput, err := runScutilOnce("list State:/Network/Service/Olm-.*/DNS\n")
if err != nil {
return fmt.Errorf("scutil list: %w", err)
}
var keys []string
scanner := bufio.NewScanner(bytes.NewReader(listOutput))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// scutil output format: subKey [0] = State:/Network/Service/Olm-Override/DNS
idx := strings.Index(line, "State:/Network/Service/Olm-")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[idx:])
if key != "" {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil
}
logger.Info("Sweeping %d orphaned Olm scutil DNS keys", len(keys))
var commands strings.Builder
for _, key := range keys {
commands.WriteString(fmt.Sprintf("remove %s\n", key))
}
if _, err := runScutilOnce(commands.String()); err != nil {
return fmt.Errorf("scutil sweep remove: %w", err)
}
return nil
}
// runScutilOnce runs a one-shot scutil command sequence wrapped with open/quit
// without requiring a configurator instance.
func runScutilOnce(commands string) ([]byte, error) {
wrapped := fmt.Sprintf("open\n%squit\n", commands)
cmd := exec.Command(scutilPath)
cmd.Stdin = strings.NewReader(wrapped)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("scutil command failed: %w, output: %s", err, output)
}
return output, nil
}

68
dns_commands.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"context"
"flag"
"fmt"
"time"
"github.com/fosrl/newt/logger"
dnsOverride "github.com/fosrl/olm/dns/override"
)
const (
defaultWatchdogInterval = 5 * time.Second
defaultWatchdogThreshold = 3
)
// runDNSWatchdogCommand handles the `olm watchdog` subcommand. The watchdog
// is meant to be spawned by an olm process after it installs a DNS
// override, and forcibly resets the system DNS if that parent dies before
// restoring it.
func runDNSWatchdogCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("watchdog", flag.ContinueOnError)
parentPID := fs.Int("parent-pid", 0, "PID of the olm process to monitor")
socketPath := fs.String("socket", "", "Path to the olm API unix socket (optional)")
interfaceName := fs.String("interface", "", "WireGuard interface name (used for cleanup)")
interval := fs.Duration("interval", defaultWatchdogInterval, "Liveness check interval")
threshold := fs.Int("threshold", defaultWatchdogThreshold, "Consecutive failures before DNS reset")
if err := fs.Parse(args); err != nil {
return err
}
if *parentPID <= 0 {
return fmt.Errorf("--parent-pid is required and must be positive")
}
// Ensure logger is initialised for the watchdog process.
logger.Init(nil)
return dnsOverride.RunWatchdog(ctx, dnsOverride.WatchdogConfig{
ParentPID: *parentPID,
SocketPath: *socketPath,
InterfaceName: *interfaceName,
CheckInterval: *interval,
FailureThreshold: *threshold,
})
}
// runResetDNSCommand handles the `olm reset-dns` subcommand. It forcibly
// removes any DNS override state left behind on the system.
func runResetDNSCommand(args []string) error {
fs := flag.NewFlagSet("reset-dns", flag.ContinueOnError)
interfaceName := fs.String("interface", "olm", "WireGuard interface name")
if err := fs.Parse(args); err != nil {
return err
}
logger.Init(nil)
if err := dnsOverride.ForceResetDNS(*interfaceName); err != nil {
return err
}
fmt.Println("DNS reset complete")
return nil
}

14
go.mod
View File

@@ -4,11 +4,11 @@ go 1.25.0
require (
github.com/Microsoft/go-winio v0.6.2
github.com/fosrl/newt v1.10.3
github.com/fosrl/newt v1.12.0
github.com/godbus/dbus/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.70
golang.org/x/sys v0.41.0
golang.org/x/sys v0.42.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
@@ -20,13 +20,13 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
)

28
go.sum
View File

@@ -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.10.3 h1:JO9gFK9LP/w2EeDIn4wU+jKggAFPo06hX5hxFSETqcw=
github.com/fosrl/newt v1.10.3/go.mod h1:iYuuCAG7iabheiogMOX87r61uQN31S39nKxMKRuLS+s=
github.com/fosrl/newt v1.12.0 h1:IodzVlsprOYkHvKrXwDfDTh2ZMtXV6IG1rhUj6Jhd44=
github.com/fosrl/newt v1.12.0/go.mod h1:IJW2sZ4WKKLRuxMz6oBm8PMyAEVkOxZk6d1OUV5/LPM=
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=
@@ -16,24 +16,24 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=

22
main.go
View File

@@ -164,6 +164,26 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Internal DNS subcommands. These are handled before normal flag
// parsing because they have their own argument layouts and need to
// run without setting up the full olm runtime.
if len(os.Args) > 1 {
switch os.Args[1] {
case "watchdog", "dns-watchdog":
if err := runDNSWatchdogCommand(signalCtx, os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "watchdog failed: %v\n", err)
os.Exit(1)
}
return
case "reset-dns":
if err := runResetDNSCommand(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "reset-dns failed: %v\n", err)
os.Exit(1)
}
return
}
}
// Run in console mode
runOlmMainWithArgs(ctx, cancel, signalCtx, os.Args[1:])
}
@@ -221,6 +241,8 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
OnExit: cancel, // Pass cancel function directly to trigger shutdown
OnTerminated: cancel,
PprofAddr: ":4444", // TODO: REMOVE OR MAKE CONFIGURABLE
// Re-invoke this binary in watchdog mode to clean up DNS if we die.
WatchdogSubcommand: []string{"watchdog"},
}
olm, err := olmpkg.Init(ctx, olmConfig)

View File

@@ -205,12 +205,13 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
// 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) {
if o.peerManager == nil || o.websocket == nil {
pm := o.getPeerManager()
if pm == nil || o.websocket == nil {
return
}
// Site already has an active peer connection - nothing to do.
if _, exists := o.peerManager.GetPeer(siteId); exists {
if _, exists := pm.GetPeer(siteId); exists {
return
}
@@ -239,6 +240,12 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
return
}
// Start the external watchdog (if configured). The watchdog will
// reset DNS if this process dies before it can call
// RestoreDNSOverride. This is a no-op when no watchdog
// subcommand has been configured on the OlmConfig.
o.startDNSWatchdog(o.tunnelConfig.InterfaceName)
network.SetDNSServers([]string{o.dnsProxy.GetProxyIP().String()})
}

View File

@@ -32,21 +32,27 @@ func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(addSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring add-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(addSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", addSubnetsData.SiteId)
return
}
// Add new subnets
for _, subnet := range addSubnetsData.RemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
if err := pm.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Add new aliases
for _, alias := range addSubnetsData.Aliases {
if err := o.peerManager.AddAlias(addSubnetsData.SiteId, alias); err != nil {
if err := pm.AddAlias(addSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
@@ -73,21 +79,27 @@ func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(removeSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring remove-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(removeSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", removeSubnetsData.SiteId)
return
}
// Remove subnets
for _, subnet := range removeSubnetsData.RemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
if err := pm.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
// Remove aliases
for _, alias := range removeSubnetsData.Aliases {
if err := o.peerManager.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
if err := pm.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
@@ -114,7 +126,13 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(updateSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring update-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(updateSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for updating remote subnets and aliases", updateSubnetsData.SiteId)
return
}
@@ -123,14 +141,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
// This ensures that if an old and new subnet are the same on different peers,
// the route won't be temporarily removed
for _, subnet := range updateSubnetsData.NewRemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
if err := pm.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Remove old subnets after new ones are added
for _, subnet := range updateSubnetsData.OldRemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
if err := pm.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
@@ -139,14 +157,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
// This ensures that if an old and new alias share the same IP, the IP won't be
// temporarily removed from the allowed IPs list
for _, alias := range updateSubnetsData.NewAliases {
if err := o.peerManager.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
if err := pm.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
// Remove old aliases after new ones are added
for _, alias := range updateSubnetsData.OldAliases {
if err := o.peerManager.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
if err := pm.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
@@ -163,7 +181,8 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
return
}
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Warn("Peer manager not initialized, ignoring sync request")
return
}
@@ -190,7 +209,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
}
// Get all current peers
currentPeers := o.peerManager.GetAllPeers()
currentPeers := pm.GetAllPeers()
currentPeerMap := make(map[int]peers.SiteConfig)
for _, peer := range currentPeers {
currentPeerMap[peer.SiteId] = peer
@@ -200,7 +219,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
for siteId := range currentPeerMap {
if _, exists := expectedPeers[siteId]; !exists {
logger.Info("Sync: Removing peer for site %d (no longer in expected config)", siteId)
if err := o.peerManager.RemovePeer(siteId); err != nil {
if err := pm.RemovePeer(siteId); err != nil {
logger.Error("Sync: Failed to remove peer %d: %v", siteId, err)
} else {
// Remove any exit nodes associated with this peer from hole punching
@@ -217,19 +236,22 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
// Find peers to add (in expected but not in current) and peers to update
for siteId, expectedSite := range expectedPeers {
if _, exists := currentPeerMap[siteId]; !exists {
// Only trigger add if this is NOT a JIT-only config (i.e., has more than just siteId and aliases)
jitOnly := expectedSite.PublicKey == ""
if jitOnly {
logger.Debug("Sync: Registering aliases for JIT-only site %d", siteId)
if err := pm.AddPeer(expectedSite); err != nil {
logger.Error("Sync: Failed to register aliases for JIT site %d: %v", siteId, err)
}
continue
}
// New peer - add it using the add flow (with holepunch)
logger.Info("Sync: Adding new peer for site %d", siteId)
o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
// // TODO: do we need to send the message to the cloud to add the peer that way?
// if err := o.peerManager.AddPeer(expectedSite); err != nil {
// logger.Error("Sync: Failed to add peer %d: %v", siteId, err)
// } else {
// logger.Info("Sync: Successfully added peer for site %d", siteId)
// }
// add the peer via the server
// this is important because newt needs to get triggered as well to add the peer once the hp is complete
chainId := fmt.Sprintf("sync-%d", expectedSite.SiteId)
@@ -301,7 +323,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
siteConfig.Aliases = expectedSite.Aliases
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
if err := pm.UpdatePeer(siteConfig); err != nil {
logger.Error("Sync: Failed to update peer %d: %v", siteId, err)
} else {
// If the endpoint changed, trigger holepunch to refresh NAT mappings

View File

@@ -9,6 +9,7 @@ import (
"net/http"
_ "net/http/pprof"
"os"
"os/exec"
"sync"
"time"
@@ -47,6 +48,7 @@ type Olm struct {
websocket *websocket.Client
holePunchManager *holepunch.Manager
peerManager *peers.PeerManager
peerManagerMu sync.RWMutex
// Power mode management
currentPowerMode string
powerModeMu sync.Mutex
@@ -67,13 +69,26 @@ type Olm struct {
stopRegister func()
updateRegister func(newData any)
stopPeerSends map[string]func()
stopPeerInits map[string]func()
stopPeerSends map[string]func()
stopPeerInits map[string]func()
jitPendingSites map[int]string // siteId -> chainId for in-flight JIT requests
peerSendMu sync.Mutex
peerSendMu sync.Mutex
// WaitGroup to track tunnel lifecycle
tunnelWg sync.WaitGroup
// External DNS watchdog process (spawned after DNS override is installed).
// nil when no watchdog is running.
dnsWatchdogCmd *exec.Cmd
}
// getPeerManager safely returns the current peerManager under a read-lock.
// Callers must check the returned value for nil before using it.
func (o *Olm) getPeerManager() *peers.PeerManager {
o.peerManagerMu.RLock()
pm := o.peerManager
o.peerManagerMu.RUnlock()
return pm
}
// initTunnelInfo creates the shared UDP socket and holepunch manager.
@@ -178,10 +193,10 @@ func Init(ctx context.Context, config OlmConfig) (*Olm, error) {
apiServer.SetAgent(config.Agent)
newOlm := &Olm{
logFile: logFile,
olmCtx: ctx,
apiServer: apiServer,
olmConfig: config,
logFile: logFile,
olmCtx: ctx,
apiServer: apiServer,
olmConfig: config,
stopPeerSends: make(map[string]func()),
stopPeerInits: make(map[string]func()),
jitPendingSites: make(map[int]string),
@@ -317,6 +332,54 @@ func (o *Olm) registerAPICallbacks() {
)
}
// startDNSWatchdog launches an external watchdog process that will reset
// system DNS if this olm process dies before it can call
// RestoreDNSOverride. It is a no-op when the OlmConfig has no
// WatchdogSubcommand configured, or when the watchdog has already been
// started for this Olm instance.
func (o *Olm) startDNSWatchdog(interfaceName string) {
if o.dnsWatchdogCmd != nil {
return
}
if len(o.olmConfig.WatchdogSubcommand) == 0 {
logger.Debug("DNS watchdog disabled (no WatchdogSubcommand configured)")
return
}
executable := o.olmConfig.WatchdogExecutable
if executable == "" {
exe, err := os.Executable()
if err != nil {
logger.Warn("DNS watchdog: failed to resolve executable: %v", err)
return
}
executable = exe
}
cmd, err := dnsOverride.SpawnWatchdog(dnsOverride.SpawnWatchdogConfig{
Executable: executable,
Subcommand: o.olmConfig.WatchdogSubcommand,
InterfaceName: interfaceName,
SocketPath: o.olmConfig.SocketPath,
LogFile: o.olmConfig.WatchdogLogFile,
})
if err != nil {
logger.Warn("DNS watchdog: spawn failed: %v", err)
return
}
o.dnsWatchdogCmd = cmd
}
// stopDNSWatchdog stops any previously spawned DNS watchdog process.
// Safe to call when no watchdog was started.
func (o *Olm) stopDNSWatchdog() {
if o.dnsWatchdogCmd == nil {
return
}
dnsOverride.StopWatchdog(o.dnsWatchdogCmd)
o.dnsWatchdogCmd = nil
}
func (o *Olm) StartTunnel(config TunnelConfig) {
if o.tunnelRunning {
logger.Info("Tunnel already running")
@@ -457,7 +520,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
"userToken": userToken,
"fingerprint": o.fingerprint,
"postures": o.postures,
}, 2*time.Second, 10)
"chainId": generateChainId(), // use a random chainId for registration updates - it won't be used for cancellation since registration is a one-time message but for tracking the session
}, 2*time.Second, 20) // after 18 tries on the server side we send the error so dont change this without changing that
// Invoke onRegistered callback if configured
if o.olmConfig.OnRegistered != nil {
@@ -585,16 +649,23 @@ func (o *Olm) Close() {
logger.Error("Failed to restore DNS: %v", err)
}
// Stop the watchdog *after* a successful DNS restore so that if we
// somehow crash mid-restore the watchdog still has a chance to clean
// up. The watchdog itself is a no-op if it was never spawned.
o.stopDNSWatchdog()
if o.holePunchManager != nil {
o.holePunchManager.Stop()
o.holePunchManager = nil
}
// Close() also calls Stop() internally
o.peerManagerMu.Lock()
if o.peerManager != nil {
o.peerManager.Close()
o.peerManager = nil
}
o.peerManagerMu.Unlock()
if o.uapiListener != nil {
_ = o.uapiListener.Close()
@@ -806,14 +877,14 @@ func (o *Olm) SetPowerMode(mode string) error {
lowPowerInterval := 10 * time.Minute
if o.peerManager != nil {
peerMonitor := o.peerManager.GetPeerMonitor()
if pm := o.getPeerManager(); pm != nil {
peerMonitor := pm.GetPeerMonitor()
if peerMonitor != nil {
peerMonitor.SetPeerInterval(lowPowerInterval, lowPowerInterval)
peerMonitor.SetPeerHolepunchInterval(lowPowerInterval, lowPowerInterval)
logger.Info("Set monitoring intervals to 10 minutes for low power mode")
}
o.peerManager.UpdateAllPeersPersistentKeepalive(0) // disable
pm.UpdateAllPeersPersistentKeepalive(0) // disable
}
if o.holePunchManager != nil {
@@ -858,14 +929,14 @@ func (o *Olm) SetPowerMode(mode string) error {
}
// Restore intervals and reconnect websocket
if o.peerManager != nil {
peerMonitor := o.peerManager.GetPeerMonitor()
if pm := o.getPeerManager(); pm != nil {
peerMonitor := pm.GetPeerMonitor()
if peerMonitor != nil {
peerMonitor.ResetPeerHolepunchInterval()
peerMonitor.ResetPeerInterval()
}
o.peerManager.UpdateAllPeersPersistentKeepalive(5)
pm.UpdateAllPeersPersistentKeepalive(5)
}
if o.holePunchManager != nil {

View File

@@ -20,7 +20,14 @@ func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
return
}
if o.peerManager == nil {
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring add-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring add-peer message: peerManager is nil (shutdown in progress)")
return
}
@@ -64,7 +71,7 @@ func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
_ = o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt so that if the peer decides to relay we have already punched close to when we need it
if err := o.peerManager.AddPeer(siteConfigMsg.SiteConfig); err != nil {
if err := pm.AddPeer(siteConfigMsg.SiteConfig); err != nil {
logger.Error("Failed to add peer: %v", err)
return
}
@@ -81,7 +88,14 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
return
}
if o.peerManager == nil {
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring remove-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring remove-peer message: peerManager is nil (shutdown in progress)")
return
}
@@ -98,7 +112,7 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
return
}
if err := o.peerManager.RemovePeer(removeData.SiteId); err != nil {
if err := pm.RemovePeer(removeData.SiteId); err != nil {
logger.Error("Failed to remove peer: %v", err)
return
}
@@ -123,7 +137,14 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
return
}
if o.peerManager == nil {
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring update-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring update-peer message: peerManager is nil (shutdown in progress)")
return
}
@@ -141,7 +162,7 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
}
// Get existing peer from PeerManager
existingPeer, exists := o.peerManager.GetPeer(updateData.SiteId)
existingPeer, exists := pm.GetPeer(updateData.SiteId)
if !exists {
logger.Warn("Peer with site ID %d not found", updateData.SiteId)
return
@@ -168,8 +189,11 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
if updateData.RemoteSubnets != nil {
siteConfig.RemoteSubnets = updateData.RemoteSubnets
}
if updateData.Aliases != nil {
siteConfig.Aliases = updateData.Aliases
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
if err := pm.UpdatePeer(siteConfig); err != nil {
logger.Error("Failed to update peer: %v", err)
return
}
@@ -177,8 +201,10 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
// If the endpoint changed, trigger holepunch to refresh NAT mappings
if updateData.Endpoint != "" && updateData.Endpoint != existingPeer.Endpoint {
logger.Info("Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", updateData.SiteId)
_ = o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
if o.holePunchManager != nil {
_ = o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
}
}
logger.Info("Successfully updated peer for site %d", updateData.SiteId)
@@ -188,7 +214,8 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
logger.Debug("Received relay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring relay message: peerManager is nil (shutdown in progress)")
return
}
@@ -208,7 +235,7 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
return
}
if monitor := o.peerManager.GetPeerMonitor(); monitor != nil {
if monitor := pm.GetPeerMonitor(); monitor != nil {
monitor.CancelRelaySend(relayData.ChainId)
}
@@ -222,14 +249,15 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.RelayEndpoint, true)
o.peerManager.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
pm.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
}
func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
logger.Debug("Received unrelay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring unrelay message: peerManager is nil (shutdown in progress)")
return
}
@@ -249,7 +277,7 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
return
}
if monitor := o.peerManager.GetPeerMonitor(); monitor != nil {
if monitor := pm.GetPeerMonitor(); monitor != nil {
monitor.CancelRelaySend(relayData.ChainId)
}
@@ -262,7 +290,7 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.Endpoint, false)
o.peerManager.UnRelayPeer(relayData.SiteId, primaryRelay)
pm.UnRelayPeer(relayData.SiteId, primaryRelay)
}
func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
@@ -317,7 +345,12 @@ func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
}
// Get existing peer from PeerManager
_, exists := o.peerManager.GetPeer(handshakeData.SiteId)
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring peer-handshake message: peerManager is nil (shutdown in progress)")
return
}
_, exists := pm.GetPeer(handshakeData.SiteId)
if exists {
logger.Warn("Peer with site ID %d already added", handshakeData.SiteId)
return

View File

@@ -48,6 +48,21 @@ type OlmConfig struct {
OnAuthError func(statusCode int, message string) // Called when auth fails (401/403)
OnOlmError func(code string, message string) // Called when registration fails
OnExit func() // Called when exit is requested via API
// DNS watchdog (optional). When WatchdogSubcommand is non-empty, the
// olm package will spawn an external watchdog subprocess after a DNS
// override is installed. The watchdog will reset the system DNS if
// this process dies before it can call RestoreDNSOverride.
//
// The watchdog is launched as:
// <WatchdogExecutable> <WatchdogSubcommand...> \
// --parent-pid=<pid> --interface=<name> [--socket=<path>]
//
// When WatchdogExecutable is empty, os.Executable() of the calling
// process is used. WatchdogLogFile defaults to /dev/null.
WatchdogExecutable string
WatchdogSubcommand []string
WatchdogLogFile string
}
type TunnelConfig struct {
@@ -61,7 +76,7 @@ type TunnelConfig struct {
MTU int
DNS string
UpstreamDNS []string
PublicDNS []string
PublicDNS []string
InterfaceName string
// Advanced

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/fosrl/newt/bind"
"github.com/fosrl/newt/logger"
@@ -32,7 +33,7 @@ type PeerManagerConfig struct {
SharedBind *bind.SharedBind
// WSClient is optional - if nil, relay messages won't be sent
WSClient *websocket.Client
APIServer *api.API
APIServer *api.API
PublicDNS []string
}
@@ -51,9 +52,12 @@ type PeerManager struct {
// key is the CIDR string, value is a set of siteIds that want this IP
allowedIPClaims map[string]map[int]bool
APIServer *api.API
publicDNS []string
publicDNS []string
PersistentKeepalive int
routeOptimizerStop chan struct{}
optimizerTrigger chan struct{}
}
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
@@ -67,7 +71,7 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
allowedIPOwners: make(map[string]int),
allowedIPClaims: make(map[string]map[int]bool),
APIServer: config.APIServer,
publicDNS: config.PublicDNS,
publicDNS: config.PublicDNS,
}
// Create the peer monitor
@@ -80,6 +84,8 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
config.PublicDNS,
)
pm.optimizerTrigger = make(chan struct{}, 1)
return pm
}
@@ -110,7 +116,7 @@ func (pm *PeerManager) GetAllPeers() []SiteConfig {
func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
pm.mu.Lock()
defer pm.mu.Unlock()
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
@@ -118,7 +124,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
}
if siteConfig.PublicKey == "" {
logger.Debug("Skip adding site %d because no pub key", siteConfig.SiteId)
return nil
@@ -156,7 +162,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
if err := network.AddRoutes(siteConfig.RemoteSubnets, pm.interfaceName); err != nil {
logger.Error("Failed to add routes for remote subnets: %v", err)
}
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
@@ -183,7 +189,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
func (pm *PeerManager) UpdateAllPeersPersistentKeepalive(interval int) map[int]error {
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.PersistentKeepalive = interval
errors := make(map[int]error)
@@ -305,6 +311,29 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
return fmt.Errorf("peer with site ID %d not found", siteConfig.SiteId)
}
// Update aliases
// Remove old aliases
for _, alias := range oldPeer.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
}
// Add new aliases
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
}
if siteConfig.PublicKey == "" {
logger.Debug("Skip updating site %d because no pub key", siteConfig.SiteId)
return nil
}
// If public key changed, remove old peer first
if siteConfig.PublicKey != oldPeer.PublicKey {
if err := RemovePeer(pm.device, siteConfig.SiteId, oldPeer.PublicKey); err != nil {
@@ -428,24 +457,6 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
}
}
// Update aliases
// Remove old aliases
for _, alias := range oldPeer.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
}
// Add new aliases
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
}
pm.peerMonitor.UpdateHolepunchEndpoint(siteConfig.SiteId, siteConfig.Endpoint)
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
@@ -856,10 +867,12 @@ func (pm *PeerManager) Start() {
if pm.peerMonitor != nil {
pm.peerMonitor.Start()
}
pm.startRouteOptimizer()
}
// Stop stops the peer monitor
func (pm *PeerManager) Stop() {
pm.stopRouteOptimizer()
if pm.peerMonitor != nil {
pm.peerMonitor.Stop()
}
@@ -867,6 +880,7 @@ func (pm *PeerManager) Stop() {
// Close stops the peer monitor and cleans up resources
func (pm *PeerManager) Close() {
pm.stopRouteOptimizer()
if pm.peerMonitor != nil {
pm.peerMonitor.Close()
pm.peerMonitor = nil
@@ -928,3 +942,166 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint)
logger.Info("Switched peer %d back to direct connection at %s", siteId, endpoint)
return nil
}
// 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,
bConn bool, bRelay bool, bRTT time.Duration) bool {
if aConn != bConn {
return aConn // connected beats disconnected
}
if !aConn {
return false // both offline, no preference
}
if aRelay != bRelay {
return !aRelay // direct beats relayed
}
// Same connectivity class: prefer lower RTT
if aRTT == 0 {
return false // unknown RTT, don't displace
}
if bRTT == 0 {
return true // current has no RTT data, prefer known
}
return aRTT < bRTT
}
// selectBestOwner returns the siteId of the best site to own the given IP,
// based on connection quality. Must be called with pm.mu held.
func (pm *PeerManager) selectBestOwner(claims map[int]bool) int {
bestSiteId := -1
var bestConn, bestRelay bool
var bestRTT time.Duration
for siteId := range claims {
conn, relay, rtt := pm.peerMonitor.GetConnectionQuality(siteId)
if bestSiteId < 0 || isBetterConnection(conn, relay, rtt, bestConn, bestRelay, bestRTT) {
bestSiteId = siteId
bestConn = conn
bestRelay = relay
bestRTT = rtt
}
}
return bestSiteId
}
// 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.
func (pm *PeerManager) getWireGuardAllowedIPs(siteId int) []string {
peer, exists := pm.peers[siteId]
if !exists {
return nil
}
serverIP := strings.Split(peer.ServerIP, "/")[0] + "/32"
ips := []string{serverIP}
for cidr, owner := range pm.allowedIPOwners {
if owner == siteId {
ips = append(ips, cidr)
}
}
return ips
}
// transferOwnership moves WireGuard ownership of cidr from fromSiteId to toSiteId.
// Must be called with pm.mu held.
func (pm *PeerManager) transferOwnership(cidr string, fromSiteId int, toSiteId int) error {
// Update owner map first
pm.allowedIPOwners[cidr] = toSiteId
// Remove cidr from old owner's WireGuard allowed IPs
if fromPeer, exists := pm.peers[fromSiteId]; exists {
remaining := pm.getWireGuardAllowedIPs(fromSiteId) // cidr is no longer in owners, so it won't appear here
if err := RemoveAllowedIP(pm.device, fromPeer.PublicKey, remaining); err != nil {
// Revert
pm.allowedIPOwners[cidr] = fromSiteId
return fmt.Errorf("remove IP %s from site %d: %v", cidr, fromSiteId, err)
}
}
// Add cidr to new owner's WireGuard allowed IPs
if toPeer, exists := pm.peers[toSiteId]; exists {
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
return fmt.Errorf("add IP %s to site %d: %v", cidr, toSiteId, err)
}
}
return nil
}
// optimizeRoutes evaluates all shared IPs and reassigns ownership to the best site.
func (pm *PeerManager) optimizeRoutes() {
pm.mu.Lock()
defer pm.mu.Unlock()
for cidr, claims := range pm.allowedIPClaims {
if len(claims) <= 1 {
continue // No competition, nothing to optimize
}
currentOwner, hasOwner := pm.allowedIPOwners[cidr]
bestOwner := pm.selectBestOwner(claims)
if bestOwner < 0 {
continue
}
if hasOwner && currentOwner == bestOwner {
continue // Already on the best site
}
if !hasOwner {
// No current owner, just assign
pm.allowedIPOwners[cidr] = bestOwner
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)
}
}
continue
}
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)
}
}
}
// startRouteOptimizer registers the status-change callback and launches the optimizer goroutine.
func (pm *PeerManager) startRouteOptimizer() {
pm.routeOptimizerStop = make(chan struct{})
// Trigger optimization whenever any peer's connection status changes
if pm.peerMonitor != nil {
pm.peerMonitor.SetStatusChangeCallback(func(_ int) {
select {
case pm.optimizerTrigger <- struct{}{}:
default:
}
})
}
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-pm.routeOptimizerStop:
return
case <-pm.optimizerTrigger:
pm.optimizeRoutes()
case <-ticker.C:
pm.optimizeRoutes()
}
}
}()
}
// stopRouteOptimizer stops the route optimizer goroutine if it is running.
func (pm *PeerManager) stopRouteOptimizer() {
if pm.routeOptimizerStop != nil {
close(pm.routeOptimizerStop)
pm.routeOptimizerStop = nil
}
}

View File

@@ -85,7 +85,9 @@ type PeerMonitor struct {
apiServer *api.API
// WG connection status tracking
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionRTT map[int]time.Duration // siteID -> last known RTT
statusChangeCallback func(siteId int) // called when any peer's connection status changes
}
// NewPeerMonitor creates a new peer monitor with the given callback
@@ -122,6 +124,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
rapidTestMaxAttempts: 5, // 5 attempts = ~1-1.5 seconds total
apiServer: apiServer,
wgConnectionStatus: make(map[int]bool),
wgConnectionRTT: make(map[int]time.Duration),
// Exponential backoff settings for holepunch monitor
defaultHolepunchMinInterval: 2 * time.Second,
defaultHolepunchMaxInterval: 30 * time.Second,
@@ -392,6 +395,9 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
pm.mutex.Lock()
previousStatus, exists := pm.wgConnectionStatus[siteID]
pm.wgConnectionStatus[siteID] = status.Connected
if status.Connected && status.RTT > 0 {
pm.wgConnectionRTT[siteID] = status.RTT
}
isRelayed := pm.relayedPeers[siteID]
endpoint := pm.holepunchEndpoints[siteID]
pm.mutex.Unlock()
@@ -409,6 +415,11 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
if pm.apiServer != nil {
pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed)
}
// Notify route optimizer of status change
if pm.statusChangeCallback != nil {
pm.statusChangeCallback(siteID)
}
}
// sendRelay sends a relay message to the server with retry, keyed by chainId
@@ -521,6 +532,25 @@ func (pm *PeerMonitor) IsPeerRelayed(siteID int) bool {
return pm.relayedPeers[siteID]
}
// SetStatusChangeCallback registers a callback that is invoked whenever a peer's
// WireGuard connection status changes (connected/disconnected). The callback must
// be non-blocking (e.g., send to a buffered channel).
func (pm *PeerMonitor) SetStatusChangeCallback(cb func(siteId int)) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.statusChangeCallback = cb
}
// GetConnectionQuality returns the current connection quality metrics for a peer.
func (pm *PeerMonitor) GetConnectionQuality(siteId int) (connected bool, relayed bool, rtt time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
connected = pm.wgConnectionStatus[siteId]
relayed = pm.relayedPeers[siteId]
rtt = pm.wgConnectionRTT[siteId]
return
}
// startHolepunchMonitor starts the holepunch connection monitoring
// Note: This function assumes the mutex is already held by the caller (called from Start())
func (pm *PeerMonitor) startHolepunchMonitor() error {