Merge branch 'dev' into msg-delivery

This commit is contained in:
Owen
2026-01-16 12:19:17 -08:00
16 changed files with 289 additions and 79 deletions

View File

@@ -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: |

23
.github/workflows/nix-build.yml vendored Normal file
View File

@@ -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

View File

@@ -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

4
.gitignore vendored
View File

@@ -5,4 +5,6 @@ nohup.out
*.iml
certs/
newt_arm64
key
key
/.direnv/
/result*

View File

@@ -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]

View File

@@ -0,0 +1,8 @@
//go:build android
package permissions
// CheckNativeInterfacePermissions always allows permission on Android.
func CheckNativeInterfacePermissions() error {
return nil
}

View File

@@ -1,4 +1,4 @@
//go:build darwin
//go:build darwin && !ios
package permissions

View File

@@ -0,0 +1,8 @@
//go:build ios
package permissions
// CheckNativeInterfacePermissions always allows permission on iOS.
func CheckNativeInterfacePermissions() error {
return nil
}

View File

@@ -1,4 +1,4 @@
//go:build linux
//go:build linux && !android
package permissions

View File

@@ -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";
};
};
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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++