From ca341a8bb07735a36e03e819c89616d68f2febfb Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 22 Dec 2025 15:05:49 -0800 Subject: [PATCH 01/16] chore(nix): sync version number with latest version --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6d2f03f..112ec02 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ inherit (pkgs) lib; # Update version when releasing - version = "1.7.0"; + version = "1.8.0"; in { default = self.packages.${system}.pangolin-newt; From f078136b5afaea86fa5e423b52ddf860469b150c Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 22 Dec 2025 15:27:21 -0800 Subject: [PATCH 02/16] fix(nix): disable tests, set meta.mainProgram for package --- flake.nix | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/flake.nix b/flake.nix index 112ec02..03e2e5f 100644 --- a/flake.nix +++ b/flake.nix @@ -37,14 +37,26 @@ vendorHash = "sha256-5Xr6mwPtsqEliKeKv2rhhp6JC7u3coP4nnhIxGMqccU="; + nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; + env = { CGO_ENABLED = 0; }; ldflags = [ + "-s" + "-w" "-X main.newtVersion=${version}" ]; + # Tests are broken due to a lack of Internet. + # Disable running `go test`, and instead do + # a simple version check instead. + doCheck = false; + doInstallCheck = true; + + versionCheckProgramArg = [ "-version" ]; + meta = { description = "A tunneling client for Pangolin"; homepage = "https://github.com/fosrl/newt"; @@ -52,6 +64,7 @@ maintainers = [ lib.maintainers.water-sucks ]; + mainProgram = "newt"; }; }; } From baf1b9b972ecca763d05ec9b28f26d4d65b14d5e Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 22 Dec 2025 15:27:49 -0800 Subject: [PATCH 03/16] ci: build nix package when go.mod is changed --- .github/workflows/nix-build.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/nix-build.yml diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 0000000..a97bcbd --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,23 @@ +name: Build Nix package + +on: + workflow_dispatch: + pull_request: + paths: + - go.mod + - go.sum + +jobs: + nix-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Build flake package + run: | + nix build .#pangolin-newt -L From 0e961761b86ec1dab25854cac605debde5bf0492 Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 22 Dec 2025 15:34:32 -0800 Subject: [PATCH 04/16] chore: add direnv and nix result dirs to gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ad2355b..e39f130 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ nohup.out *.iml certs/ newt_arm64 -key \ No newline at end of file +key +/.direnv/ +/result* From f9b6f36b4f9d43f7d7b1d30099f39796778a0d55 Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 22 Dec 2025 15:35:43 -0800 Subject: [PATCH 05/16] ci: update nix go vendor hash if needed for dependabot PRs --- .../workflows/nix-dependabot-update-hash.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/nix-dependabot-update-hash.yml diff --git a/.github/workflows/nix-dependabot-update-hash.yml b/.github/workflows/nix-dependabot-update-hash.yml new file mode 100644 index 0000000..7e255f0 --- /dev/null +++ b/.github/workflows/nix-dependabot-update-hash.yml @@ -0,0 +1,48 @@ +name: Update Nix Package Hash On Dependabot PRs + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + nix-update: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Run nix-update + run: | + nix run nixpkgs#nix-update -- --flake pangolin-newt --no-src --version skip + + - name: Check for changes + id: changes + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push changes + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "dependabot[bot]" + git config user.email "dependabot[bot]@users.noreply.github.com" + + git add . + git commit -m "chore(nix): fix hash for updated go dependencies" + git push From e1ee4dc8f21a27645f39d251515d6afd0f31304a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Dec 2025 21:32:40 -0500 Subject: [PATCH 06/16] Fix latest tag --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6474fb7..69b98b9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -273,7 +273,7 @@ jobs: tags: | type=semver,pattern={{version}},value=${{ env.TAG }} type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }} - type=raw,value=latest,enable=${{ env.PUBLISH_LATEST == 'true' && env.IS_RC != 'true' }} + type=raw,value=latest,enable=${{ env.IS_RC != 'true' }} flavor: | latest=false labels: | From d754cea397acda599028a9752cbf02e140b84a08 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Dec 2025 17:54:31 -0500 Subject: [PATCH 07/16] Dont run on v tags --- .github/workflows/cicd.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 69b98b9..4edb510 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -11,7 +11,9 @@ permissions: on: push: tags: - - "*" + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + workflow_dispatch: inputs: version: From a701add8249811c01540797be08eb6f2c94ddb78 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Dec 2025 10:57:59 -0500 Subject: [PATCH 08/16] Reuse http client for each target Fixes #220 --- healthcheck/healthcheck.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 8de3008..9b23479 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -61,6 +61,7 @@ type Target struct { timer *time.Timer ctx context.Context cancel context.CancelFunc + client *http.Client } // StatusChangeCallback is called when any target's status changes @@ -185,6 +186,16 @@ func (m *Monitor) addTargetUnsafe(config Config) error { Status: StatusUnknown, ctx: ctx, cancel: cancel, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + // Configure TLS settings based on certificate enforcement + InsecureSkipVerify: !m.enforceCert, + // Use SNI TLS header if present + ServerName: config.TLSServerName, + }, + }, + }, } m.targets[config.ID] = target @@ -378,17 +389,6 @@ func (m *Monitor) performHealthCheck(target *Target) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second) defer cancel() - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - // Configure TLS settings based on certificate enforcement - InsecureSkipVerify: !m.enforceCert, - // Use SNI TLS header if present - ServerName: target.Config.TLSServerName, - }, - }, - } - req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil) if err != nil { target.Status = StatusUnhealthy @@ -408,7 +408,7 @@ func (m *Monitor) performHealthCheck(target *Target) { } // Perform request - resp, err := client.Do(req) + resp, err := target.client.Do(req) if err != nil { target.Status = StatusUnhealthy target.LastError = fmt.Sprintf("request failed: %v", err) From 0168b4796e306ff1a3ec22bbc253d7fa9f6e98ca Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Dec 2025 10:31:35 -0500 Subject: [PATCH 09/16] Add mobile subs for permission --- clients/permissions/permissions_android.go | 8 ++++++++ clients/permissions/permissions_darwin.go | 2 +- clients/permissions/permissions_ios.go | 8 ++++++++ clients/permissions/permissions_linux.go | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 clients/permissions/permissions_android.go create mode 100644 clients/permissions/permissions_ios.go diff --git a/clients/permissions/permissions_android.go b/clients/permissions/permissions_android.go new file mode 100644 index 0000000..bbab38c --- /dev/null +++ b/clients/permissions/permissions_android.go @@ -0,0 +1,8 @@ +//go:build android + +package permissions + +// CheckNativeInterfacePermissions always allows permission on Android. +func CheckNativeInterfacePermissions() error { + return nil +} \ No newline at end of file diff --git a/clients/permissions/permissions_darwin.go b/clients/permissions/permissions_darwin.go index d14bef4..f5b48fd 100644 --- a/clients/permissions/permissions_darwin.go +++ b/clients/permissions/permissions_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && !ios package permissions diff --git a/clients/permissions/permissions_ios.go b/clients/permissions/permissions_ios.go new file mode 100644 index 0000000..d3a9cac --- /dev/null +++ b/clients/permissions/permissions_ios.go @@ -0,0 +1,8 @@ +//go:build ios + +package permissions + +// CheckNativeInterfacePermissions always allows permission on iOS. +func CheckNativeInterfacePermissions() error { + return nil +} \ No newline at end of file diff --git a/clients/permissions/permissions_linux.go b/clients/permissions/permissions_linux.go index e97ee6a..01b035a 100644 --- a/clients/permissions/permissions_linux.go +++ b/clients/permissions/permissions_linux.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux && !android package permissions From c3fad797e566e8bdaf92488670d6f5c3009419ad Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 31 Dec 2025 15:43:16 -0500 Subject: [PATCH 10/16] Handle android and ios in routes --- network/route.go | 52 ++++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/network/route.go b/network/route.go index eb850ee..d8b9940 100644 --- a/network/route.go +++ b/network/route.go @@ -217,21 +217,17 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { continue } - if runtime.GOOS == "darwin" { - if err := DarwinAddRoute(subnet, "", interfaceName); err != nil { - logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err) - return err - } - } else if runtime.GOOS == "windows" { - if err := WindowsAddRoute(subnet, "", interfaceName); err != nil { - logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err) - return err - } - } else if runtime.GOOS == "linux" { - if err := LinuxAddRoute(subnet, "", interfaceName); err != nil { - logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err) - return err - } + switch runtime.GOOS { + case "darwin": + return DarwinAddRoute(subnet, "", interfaceName) + case "windows": + return WindowsAddRoute(subnet, "", interfaceName) + case "linux": + return LinuxAddRoute(subnet, "", interfaceName) + case "android": + return nil + case "ios": + return nil } logger.Info("Added route for remote subnet: %s", subnet) @@ -258,21 +254,17 @@ func RemoveRoutes(remoteSubnets []string) error { } // Remove route based on operating system - if runtime.GOOS == "darwin" { - if err := DarwinRemoveRoute(subnet); err != nil { - logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err) - return err - } - } else if runtime.GOOS == "windows" { - if err := WindowsRemoveRoute(subnet); err != nil { - logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err) - return err - } - } else if runtime.GOOS == "linux" { - if err := LinuxRemoveRoute(subnet); err != nil { - logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err) - return err - } + switch runtime.GOOS { + case "darwin": + return DarwinRemoveRoute(subnet) + case "windows": + return WindowsRemoveRoute(subnet) + case "linux": + return LinuxRemoveRoute(subnet) + case "android": + return nil + case "ios": + return nil } logger.Info("Removed route for remote subnet: %s", subnet) From 9bb4bbccb8a3b99953cae2d6afab82475c1353c9 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 31 Dec 2025 15:58:04 -0500 Subject: [PATCH 11/16] Fix incrementor not updating; restrict routes to darwin --- network/route.go | 18 ++++++++++-------- network/settings.go | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/network/route.go b/network/route.go index d8b9940..b1b33c4 100644 --- a/network/route.go +++ b/network/route.go @@ -126,13 +126,14 @@ func LinuxRemoveRoute(destination string) error { // addRouteForServerIP adds an OS-specific route for the server IP func AddRouteForServerIP(serverIP, interfaceName string) error { - if err := AddRouteForNetworkConfig(serverIP); err != nil { - return err - } if interfaceName == "" { return nil } - if runtime.GOOS == "darwin" { + // TODO: does this also need to be ios? + if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms + if err := AddRouteForNetworkConfig(serverIP); err != nil { + return err + } return DarwinAddRoute(serverIP, "", interfaceName) } // else if runtime.GOOS == "windows" { @@ -145,13 +146,14 @@ func AddRouteForServerIP(serverIP, interfaceName string) error { // removeRouteForServerIP removes an OS-specific route for the server IP func RemoveRouteForServerIP(serverIP string, interfaceName string) error { - if err := RemoveRouteForNetworkConfig(serverIP); err != nil { - return err - } if interfaceName == "" { return nil } - if runtime.GOOS == "darwin" { + // TODO: does this also need to be ios? + if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms + if err := RemoveRouteForNetworkConfig(serverIP); err != nil { + return err + } return DarwinRemoveRoute(serverIP) } // else if runtime.GOOS == "windows" { diff --git a/network/settings.go b/network/settings.go index e7792e0..e361ba1 100644 --- a/network/settings.go +++ b/network/settings.go @@ -115,7 +115,7 @@ func RemoveIPv4IncludedRoute(route IPv4Route) { if r == route { networkSettings.IPv4IncludedRoutes = append(routes[:i], routes[i+1:]...) logger.Info("Removed IPv4 included route: %+v", route) - return + break } } incrementor++ From a62567997dc4731d2d1630d829e4789e7e897872 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 1 Jan 2026 17:29:02 -0500 Subject: [PATCH 12/16] quiet and logs and fix ios errors --- bind/shared_bind.go | 4 ++-- holepunch/tester.go | 4 ++-- network/interface.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bind/shared_bind.go b/bind/shared_bind.go index f266cb0..3625a17 100644 --- a/bind/shared_bind.go +++ b/bind/shared_bind.go @@ -523,7 +523,7 @@ func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes [ func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool { // Check if this is a test request packet if len(data) >= MagicTestRequestLen && bytes.HasPrefix(data, MagicTestRequest) { - logger.Debug("Received magic test REQUEST from %s, sending response", addr.String()) + // logger.Debug("Received magic test REQUEST from %s, sending response", addr.String()) // Extract the random data portion to echo back echoData := data[len(MagicTestRequest) : len(MagicTestRequest)+MagicPacketDataLen] @@ -546,7 +546,7 @@ func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool { // Check if this is a test response packet if len(data) >= MagicTestResponseLen && bytes.HasPrefix(data, MagicTestResponse) { - logger.Debug("Received magic test RESPONSE from %s", addr.String()) + // logger.Debug("Received magic test RESPONSE from %s", addr.String()) // Extract the echoed data echoData := data[len(MagicTestResponse) : len(MagicTestResponse)+MagicPacketDataLen] diff --git a/holepunch/tester.go b/holepunch/tester.go index 3bebc4d..6eff801 100644 --- a/holepunch/tester.go +++ b/holepunch/tester.go @@ -140,7 +140,7 @@ func (t *HolepunchTester) Stop() { // handleResponse is called by SharedBind when a magic response is received func (t *HolepunchTester) handleResponse(addr netip.AddrPort, echoData []byte) { - logger.Debug("Received magic response from %s", addr.String()) + // logger.Debug("Received magic response from %s", addr.String()) key := string(echoData) value, ok := t.pendingRequests.LoadAndDelete(key) @@ -152,7 +152,7 @@ func (t *HolepunchTester) handleResponse(addr netip.AddrPort, echoData []byte) { req := value.(*pendingRequest) rtt := time.Since(req.sentAt) - logger.Debug("Magic response matched pending request for %s (RTT: %v)", req.endpoint, rtt) + // logger.Debug("Magic response matched pending request for %s (RTT: %v)", req.endpoint, rtt) // Send RTT to the waiting goroutine (non-blocking) select { diff --git a/network/interface.go b/network/interface.go index e110ec1..5930830 100644 --- a/network/interface.go +++ b/network/interface.go @@ -44,9 +44,9 @@ func ConfigureInterface(interfaceName string, tunnelIp string, mtu int) error { return configureDarwin(interfaceName, ip, ipNet) case "windows": return configureWindows(interfaceName, ip, ipNet) - default: - return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) } + + return nil } // waitForInterfaceUp polls the network interface until it's up or times out From b84d4657634293a3aa567f7472d1ef5daa5fcc78 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 12 Jan 2026 12:31:38 -0800 Subject: [PATCH 13/16] Add noop for android ios --- network/interface.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/network/interface.go b/network/interface.go index e110ec1..3a82865 100644 --- a/network/interface.go +++ b/network/interface.go @@ -44,9 +44,13 @@ func ConfigureInterface(interfaceName string, tunnelIp string, mtu int) error { return configureDarwin(interfaceName, ip, ipNet) case "windows": return configureWindows(interfaceName, ip, ipNet) - default: - return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + case "android": + return nil + case "ios": + return nil } + + return nil } // waitForInterfaceUp polls the network interface until it's up or times out From 8c12db6dff25c6fbffb3420a219846db0a3f442b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 12 Jan 2026 14:21:05 -0800 Subject: [PATCH 14/16] Try to improve cpu usage --- holepunch/tester.go | 89 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/holepunch/tester.go b/holepunch/tester.go index 3bebc4d..babaac9 100644 --- a/holepunch/tester.go +++ b/holepunch/tester.go @@ -41,6 +41,12 @@ func DefaultTestOptions() TestConnectionOptions { } } +// cachedAddr holds a cached resolved UDP address +type cachedAddr struct { + addr *net.UDPAddr + resolvedAt time.Time +} + // HolepunchTester monitors holepunch connectivity using magic packets type HolepunchTester struct { sharedBind *bind.SharedBind @@ -53,6 +59,11 @@ type HolepunchTester struct { // Callback when connection status changes callback HolepunchStatusCallback + + // Address cache to avoid repeated DNS/UDP resolution + addrCache map[string]*cachedAddr + addrCacheMu sync.RWMutex + addrCacheTTL time.Duration // How long cached addresses are valid } // HolepunchStatus represents the status of a holepunch connection @@ -75,7 +86,9 @@ type pendingRequest struct { // NewHolepunchTester creates a new holepunch tester using the given SharedBind func NewHolepunchTester(sharedBind *bind.SharedBind) *HolepunchTester { return &HolepunchTester{ - sharedBind: sharedBind, + sharedBind: sharedBind, + addrCache: make(map[string]*cachedAddr), + addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes } } @@ -135,9 +148,67 @@ func (t *HolepunchTester) Stop() { return true }) + // Clear address cache + t.addrCacheMu.Lock() + t.addrCache = make(map[string]*cachedAddr) + t.addrCacheMu.Unlock() + logger.Debug("HolepunchTester stopped") } +// resolveEndpoint resolves an endpoint to a UDP address, using cache when possible +func (t *HolepunchTester) resolveEndpoint(endpoint string) (*net.UDPAddr, error) { + // Check cache first + t.addrCacheMu.RLock() + cached, ok := t.addrCache[endpoint] + ttl := t.addrCacheTTL + t.addrCacheMu.RUnlock() + + if ok && time.Since(cached.resolvedAt) < ttl { + return cached.addr, nil + } + + // Resolve the endpoint + host, err := util.ResolveDomain(endpoint) + if err != nil { + host = endpoint + } + + _, _, err = net.SplitHostPort(host) + if err != nil { + host = net.JoinHostPort(host, "21820") + } + + remoteAddr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve UDP address %s: %w", host, err) + } + + // Cache the result + t.addrCacheMu.Lock() + t.addrCache[endpoint] = &cachedAddr{ + addr: remoteAddr, + resolvedAt: time.Now(), + } + t.addrCacheMu.Unlock() + + return remoteAddr, nil +} + +// InvalidateCache removes a specific endpoint from the address cache +func (t *HolepunchTester) InvalidateCache(endpoint string) { + t.addrCacheMu.Lock() + delete(t.addrCache, endpoint) + t.addrCacheMu.Unlock() +} + +// ClearCache clears all cached addresses +func (t *HolepunchTester) ClearCache() { + t.addrCacheMu.Lock() + t.addrCache = make(map[string]*cachedAddr) + t.addrCacheMu.Unlock() +} + // handleResponse is called by SharedBind when a magic response is received func (t *HolepunchTester) handleResponse(addr netip.AddrPort, echoData []byte) { logger.Debug("Received magic response from %s", addr.String()) @@ -183,20 +254,10 @@ func (t *HolepunchTester) TestEndpoint(endpoint string, timeout time.Duration) T return result } - // Resolve the endpoint - host, err := util.ResolveDomain(endpoint) + // Resolve the endpoint (using cache) + remoteAddr, err := t.resolveEndpoint(endpoint) if err != nil { - host = endpoint - } - - _, _, err = net.SplitHostPort(host) - if err != nil { - host = net.JoinHostPort(host, "21820") - } - - remoteAddr, err := net.ResolveUDPAddr("udp", host) - if err != nil { - result.Error = fmt.Errorf("failed to resolve UDP address %s: %w", host, err) + result.Error = err return result } From 69952efe89f92156bab562636791d6621e49f385 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 12 Jan 2026 16:01:15 -0800 Subject: [PATCH 15/16] Fix bug where not all routes are added --- network/route.go | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/network/route.go b/network/route.go index b1b33c4..8aae063 100644 --- a/network/route.go +++ b/network/route.go @@ -221,15 +221,20 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { switch runtime.GOOS { case "darwin": - return DarwinAddRoute(subnet, "", interfaceName) + if err := DarwinAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err) + } case "windows": - return WindowsAddRoute(subnet, "", interfaceName) + if err := WindowsAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err) + } case "linux": - return LinuxAddRoute(subnet, "", interfaceName) - case "android": - return nil - case "ios": - return nil + if err := LinuxAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err) + } + case "android", "ios": + // Routes handled by the OS/VPN service + continue } logger.Info("Added route for remote subnet: %s", subnet) @@ -258,15 +263,20 @@ func RemoveRoutes(remoteSubnets []string) error { // Remove route based on operating system switch runtime.GOOS { case "darwin": - return DarwinRemoveRoute(subnet) + if err := DarwinRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err) + } case "windows": - return WindowsRemoveRoute(subnet) + if err := WindowsRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err) + } case "linux": - return LinuxRemoveRoute(subnet) - case "android": - return nil - case "ios": - return nil + if err := LinuxRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err) + } + case "android", "ios": + // Routes handled by the OS/VPN service + continue } logger.Info("Removed route for remote subnet: %s", subnet) From 060d8764296114dc40c7316c41d32e2461cb289a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 14 Jan 2026 17:09:27 -0800 Subject: [PATCH 16/16] Allow updating the intervals --- holepunch/holepunch.go | 77 +++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/holepunch/holepunch.go b/holepunch/holepunch.go index ab1d9e0..85679a9 100644 --- a/holepunch/holepunch.go +++ b/holepunch/holepunch.go @@ -38,21 +38,29 @@ type Manager struct { exitNodes map[string]ExitNode // key is endpoint updateChan chan struct{} // signals the goroutine to refresh exit nodes - sendHolepunchInterval time.Duration + sendHolepunchInterval time.Duration + sendHolepunchIntervalMin time.Duration + sendHolepunchIntervalMax time.Duration + defaultIntervalMin time.Duration + defaultIntervalMax time.Duration } -const sendHolepunchIntervalMax = 60 * time.Second -const sendHolepunchIntervalMin = 1 * time.Second +const defaultSendHolepunchIntervalMax = 60 * time.Second +const defaultSendHolepunchIntervalMin = 1 * time.Second // NewManager creates a new hole punch manager func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string) *Manager { return &Manager{ - sharedBind: sharedBind, - ID: ID, - clientType: clientType, - publicKey: publicKey, - exitNodes: make(map[string]ExitNode), - sendHolepunchInterval: sendHolepunchIntervalMin, + sharedBind: sharedBind, + ID: ID, + clientType: clientType, + publicKey: publicKey, + exitNodes: make(map[string]ExitNode), + sendHolepunchInterval: defaultSendHolepunchIntervalMin, + sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin, + sendHolepunchIntervalMax: defaultSendHolepunchIntervalMax, + defaultIntervalMin: defaultSendHolepunchIntervalMin, + defaultIntervalMax: defaultSendHolepunchIntervalMax, } } @@ -200,17 +208,46 @@ func (m *Manager) GetExitNodes() []ExitNode { return nodes } -// ResetInterval resets the hole punch interval back to the minimum value, -// allowing it to climb back up through exponential backoff. -// This is useful when network conditions change or connectivity is restored. -func (m *Manager) ResetInterval() { +// SetServerHolepunchInterval sets custom min and max intervals for hole punching. +// This is useful for low power mode where longer intervals are desired. +func (m *Manager) SetServerHolepunchInterval(min, max time.Duration) { m.mu.Lock() defer m.mu.Unlock() - if m.sendHolepunchInterval != sendHolepunchIntervalMin { - m.sendHolepunchInterval = sendHolepunchIntervalMin - logger.Info("Reset hole punch interval to minimum (%v)", sendHolepunchIntervalMin) + m.sendHolepunchIntervalMin = min + m.sendHolepunchIntervalMax = max + m.sendHolepunchInterval = min + + logger.Info("Set hole punch intervals: min=%v, max=%v", min, max) + + // Signal the goroutine to apply the new interval if running + if m.running && m.updateChan != nil { + select { + case m.updateChan <- struct{}{}: + default: + // Channel full or closed, skip + } } +} + +// GetInterval returns the current min and max intervals +func (m *Manager) GetServerHolepunchInterval() (min, max time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + return m.sendHolepunchIntervalMin, m.sendHolepunchIntervalMax +} + +// ResetServerHolepunchInterval resets the hole punch interval back to the default values. +// This restores normal operation after low power mode or other custom settings. +func (m *Manager) ResetServerHolepunchInterval() { + m.mu.Lock() + defer m.mu.Unlock() + + m.sendHolepunchIntervalMin = m.defaultIntervalMin + m.sendHolepunchIntervalMax = m.defaultIntervalMax + m.sendHolepunchInterval = m.defaultIntervalMin + + logger.Info("Reset hole punch intervals to defaults: min=%v, max=%v", m.defaultIntervalMin, m.defaultIntervalMax) // Signal the goroutine to apply the new interval if running if m.running && m.updateChan != nil { @@ -393,7 +430,7 @@ func (m *Manager) runMultipleExitNodes() { // Start with minimum interval m.mu.Lock() - m.sendHolepunchInterval = sendHolepunchIntervalMin + m.sendHolepunchInterval = m.sendHolepunchIntervalMin m.mu.Unlock() ticker := time.NewTicker(m.sendHolepunchInterval) @@ -415,7 +452,7 @@ func (m *Manager) runMultipleExitNodes() { } // Reset interval to minimum on update m.mu.Lock() - m.sendHolepunchInterval = sendHolepunchIntervalMin + m.sendHolepunchInterval = m.sendHolepunchIntervalMin m.mu.Unlock() ticker.Reset(m.sendHolepunchInterval) // Send immediate hole punch to newly resolved nodes @@ -435,8 +472,8 @@ func (m *Manager) runMultipleExitNodes() { // Exponential backoff: double the interval up to max m.mu.Lock() newInterval := m.sendHolepunchInterval * 2 - if newInterval > sendHolepunchIntervalMax { - newInterval = sendHolepunchIntervalMax + if newInterval > m.sendHolepunchIntervalMax { + newInterval = m.sendHolepunchIntervalMax } if newInterval != m.sendHolepunchInterval { m.sendHolepunchInterval = newInterval