diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6474fb7..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: @@ -273,7 +275,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: | 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 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 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* 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/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 diff --git a/flake.nix b/flake.nix index 6d2f03f..03e2e5f 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; @@ -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"; }; }; } diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 86bdc48..9889cc6 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) 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 diff --git a/holepunch/tester.go b/holepunch/tester.go index 3bebc4d..9fb83df 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,12 +148,70 @@ 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()) + // logger.Debug("Received magic response from %s", addr.String()) key := string(echoData) value, ok := t.pendingRequests.LoadAndDelete(key) @@ -152,7 +223,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 { @@ -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 } diff --git a/network/interface.go b/network/interface.go index e110ec1..70556be 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 diff --git a/network/route.go b/network/route.go index eb850ee..8aae063 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" { @@ -217,21 +219,22 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { continue } - if runtime.GOOS == "darwin" { + switch runtime.GOOS { + case "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" { + case "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" { + case "linux": if err := LinuxAddRoute(subnet, "", interfaceName); err != nil { logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err) - return err } + case "android", "ios": + // Routes handled by the OS/VPN service + continue } logger.Info("Added route for remote subnet: %s", subnet) @@ -258,21 +261,22 @@ func RemoveRoutes(remoteSubnets []string) error { } // Remove route based on operating system - if runtime.GOOS == "darwin" { + switch runtime.GOOS { + case "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" { + case "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" { + case "linux": if err := LinuxRemoveRoute(subnet); err != nil { logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err) - return err } + case "android", "ios": + // Routes handled by the OS/VPN service + continue } logger.Info("Removed route for remote subnet: %s", subnet) 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++