Compare commits

..
Author SHA1 Message Date
Owen 6b6bb89dd3 Support aliased SITE_ID and SITE_SECRET 2026-09-03 17:21:58 -04:00
Owen db891e3d7d Support importing into the cli 2026-09-03 16:57:39 -04:00
Owen f0114b496e Add support for local endpoint interface allowlist 2026-09-01 17:23:35 -04:00
Owen e2139d0720 Add nil check for netstack in ping function and implement regression test
Fixes #439
2026-08-28 09:20:00 -04:00
Owen 21161dfbe5 Fix #437 2026-08-24 11:58:41 -04:00
20 changed files with 519 additions and 260 deletions
+27 -27
View File
@@ -68,7 +68,7 @@ jobs:
echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
@@ -95,7 +95,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -158,7 +158,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -240,17 +240,17 @@ jobs:
# uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
#- name: Set up Docker Buildx
# uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -264,7 +264,7 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (amd64 -> *:amd64-TAG)
@@ -309,7 +309,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -368,14 +368,14 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -389,7 +389,7 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (arm64 -> *:arm64-TAG)
@@ -434,7 +434,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -483,14 +483,14 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -507,7 +507,7 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build and push (arm/v7 -> *:armv7-TAG)
id: build_armv7
@@ -556,14 +556,14 @@ jobs:
#PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }}
steps:
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -577,7 +577,7 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
shell: bash
@@ -642,7 +642,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -656,19 +656,19 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -692,7 +692,7 @@ jobs:
sudo apt-get install -y jq
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Resolve multi-arch digest refs (by TAG)
shell: bash
@@ -732,7 +732,7 @@ jobs:
fi
- name: Attest build provenance (GHCR) (digest)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.GHCR_IMAGE }}
subject-digest: ${{ env.GHCR_DIGEST }}
@@ -742,7 +742,7 @@ jobs:
- name: Attest build provenance (Docker Hub)
continue-on-error: true
if: ${{ env.DH_DIGEST != '' }}
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
subject-digest: ${{ env.DH_DIGEST }}
@@ -912,7 +912,7 @@ jobs:
done
- name: Create GitHub Release (draft)
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ env.TAG }}
generate_release_notes: true
@@ -940,7 +940,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
uses: actions/checkout@v7
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-stale: 14
days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
@@ -42,10 +42,10 @@ jobs:
- go-build-release-windows-amd64
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
+38 -31
View File
@@ -98,20 +98,21 @@ type PeerReading struct {
}
type WireGuardService struct {
interfaceName string
mtu int
client *websocket.Client
config WgConfig
key wgtypes.Key
newtId string
lastReadings map[string]PeerReading
mu sync.Mutex
Port uint16
host string
serverPubKey string
token string
stopGetConfig func()
pendingConfigChainId string
interfaceName string
localEndpointInterfaces []string
mtu int
client *websocket.Client
config WgConfig
key wgtypes.Key
newtId string
lastReadings map[string]PeerReading
mu sync.Mutex
Port uint16
host string
serverPubKey string
token string
stopGetConfig func()
pendingConfigChainId string
// Netstack fields
tun tun.Device
tnet *netstack2.Net
@@ -154,7 +155,7 @@ func generateChainId() string {
return hex.EncodeToString(b)
}
func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) {
func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool, localEndpointInterfaces []string) (*WireGuardService, error) {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %v", err)
@@ -195,17 +196,18 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
dnsAddrs := []netip.Addr{netip.MustParseAddr(dns)}
service := &WireGuardService{
interfaceName: interfaceName,
mtu: mtu,
client: wsClient,
key: key,
newtId: newtId,
host: host,
lastReadings: make(map[string]PeerReading),
Port: port,
dns: dnsAddrs,
sharedBind: sharedBind,
useNativeInterface: useNativeInterface,
interfaceName: interfaceName,
localEndpointInterfaces: localEndpointInterfaces,
mtu: mtu,
client: wsClient,
key: key,
newtId: newtId,
host: host,
lastReadings: make(map[string]PeerReading),
Port: port,
dns: dnsAddrs,
sharedBind: sharedBind,
useNativeInterface: useNativeInterface,
}
// Create the holepunch manager
@@ -532,7 +534,7 @@ func (s *WireGuardService) LoadRemoteConfig() error {
"publicKey": s.key.PublicKey().String(),
"port": s.Port,
"chainId": chainId,
"localEndpoints": network.GetLocalEndpoints(s.Port, s.interfaceName),
"localEndpoints": network.GetLocalEndpoints(s.Port, s.interfaceName, s.localEndpointInterfaces),
}, 2*time.Second)
logger.Debug("Requesting WireGuard configuration from remote server")
@@ -832,11 +834,16 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
s.mu.Lock()
interfaceAddress, tunnelIP, err := normalizeInterfaceAddress(wgconfig.IpAddress)
if err != nil {
// split off the cidr from the IP address
parts := strings.Split(wgconfig.IpAddress, "/")
if len(parts) != 2 {
s.mu.Unlock()
return err
return fmt.Errorf("invalid IP address format: %s", wgconfig.IpAddress)
}
// Parse the IP address and CIDR mask
tunnelIP := netip.MustParseAddr(parts[0])
var err error
if s.useNativeInterface {
// Create native TUN device
@@ -912,7 +919,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
}
// Configure the network interface with IP address
if err := network.ConfigureInterface(interfaceName, interfaceAddress, s.mtu); err != nil {
if err := network.ConfigureInterface(interfaceName, wgconfig.IpAddress, s.mtu); err != nil {
s.mu.Unlock()
return fmt.Errorf("failed to configure interface: %v", err)
}
-23
View File
@@ -1,23 +0,0 @@
package clients
import (
"fmt"
"net/netip"
)
// normalizeInterfaceAddress accepts the address shape emitted by Pangolin's
// site configuration. Older Pangolin versions can send a bare host address;
// the client WireGuard interface needs an address/prefix pair, so treat a
// bare IPv4 address as /32 and a bare IPv6 address as /128.
func normalizeInterfaceAddress(raw string) (string, netip.Addr, error) {
if prefix, err := netip.ParsePrefix(raw); err == nil {
return prefix.String(), prefix.Addr(), nil
}
addr, err := netip.ParseAddr(raw)
if err != nil {
return "", netip.Addr{}, fmt.Errorf("invalid IP address format: %s", raw)
}
return netip.PrefixFrom(addr, addr.BitLen()).String(), addr, nil
}
-34
View File
@@ -1,34 +0,0 @@
package clients
import "testing"
func TestNormalizeInterfaceAddress(t *testing.T) {
tests := []struct {
name string
input string
wantCIDR string
wantIP string
wantErr bool
}{
{name: "bare IPv4", input: "100.90.128.4", wantCIDR: "100.90.128.4/32", wantIP: "100.90.128.4"},
{name: "bare IPv6", input: "2001:db8::4", wantCIDR: "2001:db8::4/128", wantIP: "2001:db8::4"},
{name: "IPv4 prefix", input: "100.90.128.4/24", wantCIDR: "100.90.128.4/24", wantIP: "100.90.128.4"},
{name: "IPv6 prefix", input: "2001:db8::4/64", wantCIDR: "2001:db8::4/64", wantIP: "2001:db8::4"},
{name: "invalid", input: "100.90.128.4/33", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCIDR, gotIP, err := normalizeInterfaceAddress(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("normalizeInterfaceAddress(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if tt.wantErr {
return
}
if gotCIDR != tt.wantCIDR || gotIP.String() != tt.wantIP {
t.Fatalf("normalizeInterfaceAddress(%q) = (%q, %q), want (%q, %q)", tt.input, gotCIDR, gotIP, tt.wantCIDR, tt.wantIP)
}
})
}
}
+12
View File
@@ -87,6 +87,18 @@ func attrsWithSite(extra ...attribute.KeyValue) []attribute.KeyValue {
return attrs
}
// EnsureInstruments registers this package's instruments against whichever
// MeterProvider is globally active (the no-op provider if Init has not been
// called), so that the Inc*/Observe* recording functions below are always
// safe to call. Idempotent and cheap to call repeatedly or before Init - Init
// calls it too, and OTel's global API transparently upgrades instruments
// created this way once a real MeterProvider is later installed via
// otel.SetMeterProvider, so calling it early does not affect metrics
// exported once Init runs.
func EnsureInstruments() error {
return registerInstruments()
}
func registerInstruments() error {
var err error
initOnce.Do(func() {
+9 -5
View File
@@ -16,6 +16,7 @@ import (
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
newtpkg "github.com/fosrl/newt/newt"
"github.com/fosrl/newt/newtconfig"
"github.com/fosrl/newt/updates"
"github.com/fosrl/newt/websocket"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
@@ -62,7 +63,14 @@ func main() {
func runNewtMain(ctx context.Context) {
logger.Init(nil)
cfg := loadNewtConfig()
cfg, err := newtconfig.Load(newtconfig.Options{
Args: os.Args[1:],
Version: newtVersion,
Platform: newtPlatform,
})
if err != nil {
logger.Fatal("Configuration error: %v", err)
}
if cfg.UseNativeMainInterface {
if err := permissions.CheckNativeInterfacePermissions(); err != nil {
@@ -70,10 +78,6 @@ func runNewtMain(ctx context.Context) {
}
}
if err := validateTLSConfig(cfg); err != nil {
logger.Fatal("TLS configuration error: %v", err)
}
logger.Debug("Endpoint: %v", cfg.Endpoint)
logger.Debug("Log Level: %v", cfg.LogLevel)
logger.Debug("Docker Network Validation Enabled: %v", cfg.DockerEnforceNetworkValidation)
+16 -30
View File
@@ -496,42 +496,28 @@ func (h *ICMPHandler) handleICMPPacket(id stack.TransportEndpointID, pkt *stack.
logger.Info("ICMP Handler: Echo Request from %s to %s (ident=%d, seq=%d)",
srcIP, dstIP, icmpHdr.Ident(), icmpHdr.Sequence())
// Convert to netip.Addr for subnet matching
srcAddr, err := netip.ParseAddr(srcIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse source IP %s: %v", srcIP, err)
return false
}
dstAddr, err := netip.ParseAddr(dstIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse dest IP %s: %v", dstIP, err)
return false
}
// Check subnet rules (use port 0 for ICMP since it doesn't have ports)
if h.proxyHandler == nil {
logger.Debug("ICMP Handler: No proxy handler configured")
return false
}
matchedRule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, 0, header.ICMPv4ProtocolNumber)
if matchedRule == nil {
logger.Debug("ICMP Handler: No matching subnet rule for %s -> %s", srcIP, dstIP)
return false
}
logger.Info("ICMP Handler: Matched subnet rule for %s -> %s", srcIP, dstIP)
// Determine actual destination (with possible rewrite)
// This packet only reached the proxy stack because it already matched a
// subnet rule during injection (ProxyHandler.HandleIncomingPacket), so
// there's no need to re-run subnet matching here for permission - doing
// so used to re-derive the DNAT target from a *fresh* rule lookup keyed
// on dstIP, but dstIP here is ambiguous: for a loopback rewrite target
// it's still the original (unrewritten) destination, while for a
// non-loopback rewrite target it's already the post-DNAT address. In
// the latter case (and always for a domain-name RewriteTo, which has no
// subnet rule of its own for the resolved IP) that re-lookup could find
// no rule and silently drop the ping even though the connection is
// legitimately allowed. Instead, resolve the same way the TCP/UDP
// handlers do: via destRewriteTable, which HandleIncomingPacket already
// populated for this exact flow keyed by the original destination.
actualDstIP := dstIP
if matchedRule.RewriteTo != "" {
resolvedAddr, err := h.proxyHandler.resolveRewriteAddress(matchedRule.RewriteTo)
if err != nil {
logger.Info("ICMP Handler: Failed to resolve rewrite address %s: %v", matchedRule.RewriteTo, err)
} else {
actualDstIP = resolvedAddr.String()
logger.Info("ICMP Handler: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
if rewrittenAddr, ok := h.proxyHandler.LookupDestinationRewrite(srcIP, dstIP, 0, uint8(header.ICMPv4ProtocolNumber)); ok {
actualDstIP = rewrittenAddr.String()
logger.Info("ICMP Handler: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
// Get the full ICMP payload (including the data after the header)
+152
View File
@@ -0,0 +1,152 @@
package netstack2
import (
"bytes"
"io"
"net/netip"
"os"
"strings"
"testing"
"time"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// buildICMPEchoRequest builds a minimal, checksummed IPv4 ICMP echo request
// packet from src to dst.
func buildICMPEchoRequest(t *testing.T, src, dst netip.Addr) []byte {
t.Helper()
const icmpSize = header.ICMPv4MinimumSize
totalLen := header.IPv4MinimumSize + icmpSize
pkt := make([]byte, totalLen)
ip := header.IPv4(pkt)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(totalLen),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4(src.As4()),
DstAddr: tcpip.AddrFrom4(dst.As4()),
})
ip.SetChecksum(0)
ip.SetChecksum(^ip.CalculateChecksum())
icmp := header.ICMPv4(pkt[header.IPv4MinimumSize:])
icmp.SetType(header.ICMPv4Echo)
icmp.SetCode(0)
icmp.SetIdent(1)
icmp.SetSequence(1)
icmp.SetChecksum(0)
icmp.SetChecksum(header.ICMPv4Checksum(icmp, checksum.Checksum(icmp.Payload(), 0)))
return pkt
}
// noopNotification is a no-op channel.Notification for tests that don't
// care about read-availability notifications.
type noopNotification struct{}
func (noopNotification) WriteNotify() {}
// captureLogOutput redirects the package logger to a pipe for the duration
// of fn, and returns everything written to it. Needed here because
// ICMPHandler.handleICMPPacket runs on its own goroutine off of
// HandleIncomingPacket and reports its outcome only via log lines.
func captureLogOutput(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
logger.SetOutput(w)
defer logger.SetOutput(os.Stdout)
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
io.Copy(&buf, r)
done <- buf.String()
}()
fn()
// handleICMPPacket runs asynchronously (go h.proxyPing(...)); give it a
// moment to log its outcome before we stop capturing.
time.Sleep(200 * time.Millisecond)
w.Close()
return <-done
}
// A DNAT target whose RewriteTo isn't independently reachable as its own
// destination (e.g. a resolved domain name, or - as here - just an IP with
// no mirrored direct subnet rule) used to make ICMP echo requests get
// silently dropped: the old ICMPHandler re-derived the DNAT target by
// running a fresh SubnetLookup.Match() against whatever address the packet
// carried by the time it reached the handler, which for a non-loopback
// rewrite is already the post-DNAT address - and no rule exists for that
// address on its own. The fix resolves the real target via
// destRewriteTable (LookupDestinationRewrite) instead, exactly like the
// TCP/UDP handlers already did, so this no longer depends on a mirrored
// direct rule existing for the rewritten address.
func TestICMPHandleIncomingPacket_DNATWithoutMirroredDirectRule(t *testing.T) {
ph, err := NewProxyHandler(ProxyHandlerOptions{EnableICMP: true, MTU: 1500})
if err != nil {
t.Fatalf("NewProxyHandler: %v", err)
}
if err := ph.Initialize(noopNotification{}); err != nil {
t.Fatalf("Initialize: %v", err)
}
defer ph.Close()
srcAddr := netip.MustParseAddr("10.0.0.5")
aliasAddr := netip.MustParseAddr("10.20.20.9")
realAddr := netip.MustParseAddr("203.0.113.50")
ph.AddSubnetRule(SubnetRule{
SourcePrefix: netip.MustParsePrefix("10.0.0.0/24"),
DestPrefix: netip.PrefixFrom(aliasAddr, 32),
RewriteTo: realAddr.String() + "/32",
})
pkt := buildICMPEchoRequest(t, srcAddr, aliasAddr)
var injected bool
logs := captureLogOutput(t, func() {
injected = ph.HandleIncomingPacket(pkt)
})
if !injected {
t.Fatal("expected ICMP echo request to be matched and injected")
}
// destRewriteTable is populated by HandleIncomingPacket itself, so this
// much holds regardless of the bug - included here to pin the mechanism
// the fix relies on.
got, ok := ph.LookupDestinationRewrite(srcAddr.String(), aliasAddr.String(), 0, uint8(header.ICMPv4ProtocolNumber))
if !ok {
t.Fatal("expected destRewriteTable to have an entry for this ICMP flow")
}
if got != realAddr {
t.Fatalf("expected rewritten destination %s, got %s", realAddr, got)
}
// This is the actual regression check: with the bug, handleICMPPacket
// re-matches on the already-rewritten address, finds no rule for it,
// and drops the echo request before ever attempting to proxy it.
if strings.Contains(logs, "No matching subnet rule") {
t.Errorf("ICMP handler dropped the echo request instead of proxying it; logs:\n%s", logs)
}
if !strings.Contains(logs, "Proxying ping from") {
t.Errorf("expected ICMP handler to proxy the ping to the rewritten destination; logs:\n%s", logs)
}
if !strings.Contains(logs, realAddr.String()) {
t.Errorf("expected logs to reference the rewritten destination %s; logs:\n%s", realAddr, logs)
}
}
+18 -1
View File
@@ -98,15 +98,27 @@ func interfaceScore(name string) int {
// name of our own WireGuard/TUN interface, whose address is the tunnel IP
// and not a useful endpoint to advertise.
//
// allowedInterfaces, if non-empty, restricts the result to only those
// interface names (an allowlist), letting callers report a single known-good
// interface instead of every candidate on the host.
//
// If interfaces cannot be enumerated (e.g. insufficient OS permissions),
// an info message is logged and an empty slice is returned.
func GetLocalEndpoints(port uint16, excludeInterface string) []string {
func GetLocalEndpoints(port uint16, excludeInterface string, allowedInterfaces []string) []string {
ifaces, err := net.Interfaces()
if err != nil {
logger.Info("Unable to enumerate local network interfaces, localEndpoints will not be reported: %v", err)
return nil
}
var allowedSet map[string]struct{}
if len(allowedInterfaces) > 0 {
allowedSet = make(map[string]struct{}, len(allowedInterfaces))
for _, name := range allowedInterfaces {
allowedSet[name] = struct{}{}
}
}
type candidate struct {
score int
ip string
@@ -117,6 +129,11 @@ func GetLocalEndpoints(port uint16, excludeInterface string) []string {
if excludeInterface != "" && iface.Name == excludeInterface {
continue
}
if allowedSet != nil {
if _, ok := allowedSet[iface.Name]; !ok {
continue
}
}
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
+1
View File
@@ -42,6 +42,7 @@ func (n *Newt) setupClients() {
n.client,
n.config.DNS,
n.config.UseNativeInterface,
n.config.LocalEndpointInterfaces,
)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
+1
View File
@@ -29,6 +29,7 @@ type Config struct {
NativeMainInterfaceName string
NoCloud bool
PreferEndpoint string
LocalEndpointInterfaces []string
// Timing
PingInterval time.Duration
+9
View File
@@ -13,6 +13,7 @@ import (
wgclients "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/docker"
"github.com/fosrl/newt/healthcheck"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
"github.com/fosrl/newt/proxy"
@@ -82,6 +83,14 @@ type Newt struct {
func Init(ctx context.Context, cfg Config) (*Newt, error) {
n := &Newt{config: cfg}
// Metric-recording calls throughout the websocket/proxy/tunnel code are
// unconditional, not gated on cfg.MetricsEnabled, so the instruments
// must exist even when the caller never sets up telemetry exporters
// (e.g. an embedder that only calls Init/Start, like the Pangolin CLI).
if err := telemetry.EnsureInstruments(); err != nil {
return nil, fmt.Errorf("failed to initialize telemetry instruments: %w", err)
}
n.loggerLevel = util.ParseLogLevel(cfg.LogLevel)
if !cfg.DisableSSH {
+11
View File
@@ -342,6 +342,17 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
}
}
// TestPingNilNetstackReturnsError is the regression guard for fosrl/newt#439:
// a still-running ping goroutine calling ping() with a nil *netstack.Net
// (after closeWgTunnel clears it during teardown/reconnect) must return an
// error instead of panicking with a nil pointer dereference.
func TestPingNilNetstackReturnsError(t *testing.T) {
_, err := ping(nil, "127.0.0.1", 100*time.Millisecond)
if err == nil {
t.Fatal("expected error when tnet is nil, got nil")
}
}
// TestShouldFireRecovery is the regression guard for the broken trigger gate
// that prevented data-plane recovery from ever firing under default settings
// (fosrl/newt#284, #310, pangolin#1004).
+4
View File
@@ -47,6 +47,10 @@ func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
}
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
if tnet == nil {
return 0, fmt.Errorf("netstack not initialized")
}
socket, err := tnet.Dial("ping4", dst)
if err != nil {
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
+148 -70
View File
@@ -1,4 +1,10 @@
package main
// Package newtconfig resolves Newt's runtime configuration (CLI flags, env
// vars, and config file) into a newt.Config. It is the same logic used by
// the newt binary's entrypoint, factored out so other programs (such as the
// Pangolin CLI, which embeds Newt as a library) can load configuration the
// exact same way, from an explicit argument list rather than the process's
// global os.Args/flag.CommandLine.
package newtconfig
import (
"encoding/json"
@@ -53,11 +59,12 @@ type fileSettings struct {
MTU *int `json:"mtu"`
Port *int `json:"port"`
UseNativeInterface *bool `json:"native"`
UseNativeMainInterface *bool `json:"nativeMain"`
NativeMainInterfaceName *string `json:"interfaceMain"`
NoCloud *bool `json:"noCloud"`
PreferEndpoint *string `json:"preferEndpoint"`
UseNativeInterface *bool `json:"native"`
UseNativeMainInterface *bool `json:"nativeMain"`
NativeMainInterfaceName *string `json:"interfaceMain"`
NoCloud *bool `json:"noCloud"`
PreferEndpoint *string `json:"preferEndpoint"`
LocalEndpointInterfaces []string `json:"localEndpointInterfaces"`
PingInterval *string `json:"pingInterval"`
PingTimeout *string `json:"pingTimeout"`
@@ -93,8 +100,9 @@ type fileSettings struct {
// resolveConfigFilePath determines the settings/credentials file path using
// the same precedence as every other setting: CLI > env > OS default.
// It has to run before flag.Parse (which needs the file-resolved defaults),
// so it scans os.Args directly instead of using the flag package.
// It has to run before the flag set is parsed (which needs the
// file-resolved defaults), so it scans args directly instead of using the
// flag package.
func resolveConfigFilePath(args []string) string {
for i, a := range args {
if a == "--config-file" || a == "-config-file" {
@@ -180,6 +188,20 @@ func applyEnvBool(dst *bool, envName, key string, sources map[string]string) {
}
}
// applyEnvStrAlias behaves like applyEnvStr, but checks a preferred env var
// first and only falls back to an alias name when the preferred one is unset.
func applyEnvStrAlias(dst *string, envName, aliasEnvName, key string, sources map[string]string) {
if v := os.Getenv(envName); v != "" {
*dst = v
sources[key] = string(sourceEnv)
return
}
if v := os.Getenv(aliasEnvName); v != "" {
*dst = v
sources[key] = string(sourceEnv)
}
}
// validateTLSConfig validates that TLS config fields are consistent and that
// referenced files exist.
func validateTLSConfig(cfg newtpkg.Config) error {
@@ -234,14 +256,28 @@ func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) ti
return d
}
// loadNewtConfig resolves configuration with priority cli > env > file >
// default, then returns a populated newtpkg.Config. This function calls
// flag.Parse internally and will exit the process if --version or
// --show-config is passed.
func loadNewtConfig() newtpkg.Config {
// Options controls how Load resolves configuration.
type Options struct {
// Args are the newt command-line arguments, i.e. os.Args[1:] when newt
// is run as its own binary, or whatever arguments were passed to a
// subcommand that embeds newt as a library.
Args []string
// Version and Platform populate the resulting Config's build info and
// are printed by --version.
Version string
Platform string
}
// Load resolves configuration with priority cli > env > file > default,
// validates it (e.g. TLS flag consistency and referenced file existence),
// and returns a populated newtpkg.Config. This function parses Args with a
// dedicated flag.FlagSet (safe to call more than once per process) and will
// exit the process if --version or --show-config is passed, matching the
// newt binary's own CLI behavior exactly.
func Load(opts Options) (newtpkg.Config, error) {
sources := make(map[string]string)
configPath := resolveConfigFilePath(os.Args[1:])
configPath := resolveConfigFilePath(opts.Args)
fileCfg, err := loadFileSettings(configPath)
if err != nil {
logger.Fatal("Failed to load config file: %v", err)
@@ -249,8 +285,8 @@ func loadNewtConfig() newtpkg.Config {
// ---- defaults ----
cfg := newtpkg.Config{
Version: newtVersion,
Platform: newtPlatform,
Version: opts.Version,
Platform: opts.Platform,
DNS: "9.9.9.9",
LogLevel: "INFO",
@@ -294,6 +330,10 @@ func loadNewtConfig() newtpkg.Config {
applyStr(&cfg.NativeMainInterfaceName, fileCfg.NativeMainInterfaceName, "interface-main", sources, sourceFile)
applyBool(&cfg.NoCloud, fileCfg.NoCloud, "no-cloud", sources, sourceFile)
applyStr(&cfg.PreferEndpoint, fileCfg.PreferEndpoint, "prefer-endpoint", sources, sourceFile)
if len(fileCfg.LocalEndpointInterfaces) > 0 {
cfg.LocalEndpointInterfaces = fileCfg.LocalEndpointInterfaces
sources["local-endpoint-interfaces"] = string(sourceFile)
}
applyStr(&pingIntervalStr, fileCfg.PingInterval, "ping-interval", sources, sourceFile)
applyStr(&pingTimeoutStr, fileCfg.PingTimeout, "ping-timeout", sources, sourceFile)
@@ -335,8 +375,12 @@ func loadNewtConfig() newtpkg.Config {
// ---- layer 2: environment variables ----
applyEnvStr(&cfg.Endpoint, "PANGOLIN_ENDPOINT", "endpoint", sources)
applyEnvStr(&cfg.ID, "NEWT_ID", "id", sources)
applyEnvStr(&cfg.Secret, "NEWT_SECRET", "secret", sources)
// SITE_ID/SITE_SECRET are accepted as aliases for NEWT_ID/NEWT_SECRET
// (NEWT_ID/NEWT_SECRET win if both are set) so a site tunnel's
// credentials can be named consistently with other Pangolin CLI
// connection types (e.g. CLIENT_ID/CLIENT_SECRET for `up client`).
applyEnvStrAlias(&cfg.ID, "NEWT_ID", "SITE_ID", "id", sources)
applyEnvStrAlias(&cfg.Secret, "NEWT_SECRET", "SITE_SECRET", "secret", sources)
applyEnvStr(&cfg.ProvisioningKey, "NEWT_PROVISIONING_KEY", "provisioning-key", sources)
applyEnvStr(&cfg.NewtName, "NEWT_NAME", "name", sources)
@@ -351,6 +395,16 @@ func loadNewtConfig() newtpkg.Config {
applyEnvBool(&cfg.UseNativeMainInterface, "USE_NATIVE_MAIN_INTERFACE", "native-main", sources)
applyEnvStr(&cfg.NativeMainInterfaceName, "INTERFACE_MAIN", "interface-main", sources)
applyEnvBool(&cfg.NoCloud, "NO_CLOUD", "no-cloud", sources)
if v := os.Getenv("LOCAL_ENDPOINT_INTERFACES"); v != "" {
var names []string
for _, n := range strings.Split(v, ",") {
if t := strings.TrimSpace(n); t != "" {
names = append(names, t)
}
}
cfg.LocalEndpointInterfaces = names
sources["local-endpoint-interfaces"] = string(sourceEnv)
}
applyEnvStr(&pingIntervalStr, "PING_INTERVAL", "ping-interval", sources)
applyEnvStr(&pingTimeoutStr, "PING_TIMEOUT", "ping-timeout", sources)
@@ -416,64 +470,69 @@ func loadNewtConfig() newtpkg.Config {
origTLSCert, origTLSKey, origDockerEnforce := cfg.TLSClientCert, cfg.TLSClientKey, dockerEnforceStr
origHealthFile, origBlueprintFile, origProvBlueprintFile := cfg.HealthFile, cfg.BlueprintFile, cfg.ProvisioningBlueprintFile
origNoCloud, origTLSPrivateKey := cfg.NoCloud, cfg.TLSPrivateKey
localEndpointInterfacesStr := strings.Join(cfg.LocalEndpointInterfaces, ",")
origLocalEndpointInterfaces := localEndpointInterfacesStr
origMetrics, origOTLP, origAdminAddr := cfg.MetricsEnabled, cfg.OTLPEnabled, cfg.AdminAddr
origMetricsAsync, origPprof, origRegion := cfg.MetricsAsyncBytes, cfg.PprofEnabled, cfg.Region
origADKey, origADPrincipals, origADCACert := cfg.AuthDaemonKey, cfg.AuthDaemonPrincipalsFile, cfg.AuthDaemonCACertPath
origADRandomPass := cfg.AuthDaemonGenerateRandomPassword
flag.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server")
flag.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID")
flag.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret")
flag.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use")
flag.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use")
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
flag.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed")
flag.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface")
flag.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface")
flag.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels")
flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)")
fs := flag.NewFlagSet("newt", flag.ExitOnError)
fs.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server")
fs.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID")
fs.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret")
fs.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use")
fs.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use")
fs.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
fs.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed")
fs.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface")
fs.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface")
fs.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels")
fs.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)")
// making this the same as above should prevent them from running together
flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)")
flag.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface")
flag.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)")
flag.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
flag.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)")
flag.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)")
flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup")
flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server")
flag.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
flag.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)")
flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)")
flag.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)")
fs.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)")
fs.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface")
fs.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
fs.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)")
fs.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
fs.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)")
fs.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)")
fs.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup")
fs.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
fs.StringVar(&localEndpointInterfacesStr, "local-endpoint-interfaces", localEndpointInterfacesStr, "Comma-separated list of network interface names to restrict reported local endpoints to (default: report all interfaces)")
fs.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server")
fs.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
fs.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)")
fs.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)")
fs.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)")
// Backward-compat dummy flag (auth daemon is always enabled now)
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
fs.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
var tlsClientCAsFlag stringSlice
flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
fs.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)")
flag.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)")
flag.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)")
flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
flag.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover")
flag.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter")
flag.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address")
flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)")
flag.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server")
flag.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)")
flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication")
flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon")
flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon")
flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users")
fs.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
fs.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)")
fs.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)")
fs.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)")
fs.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
fs.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover")
fs.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter")
fs.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
fs.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address")
fs.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)")
fs.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server")
fs.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)")
fs.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication")
fs.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon")
fs.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon")
fs.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users")
version := flag.Bool("version", false, "Print the version")
showConfig := flag.Bool("show-config", false, "Show configuration values and their sources, then exit")
version := fs.Bool("version", false, "Print the version")
showConfig := fs.Bool("show-config", false, "Show configuration values and their sources, then exit")
flag.Parse()
fs.Parse(opts.Args)
// ---- post-parse processing ----
@@ -517,6 +576,7 @@ func loadNewtConfig() newtpkg.Config {
markCLI("blueprint-file", cfg.BlueprintFile != origBlueprintFile)
markCLI("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile != origProvBlueprintFile)
markCLI("no-cloud", cfg.NoCloud != origNoCloud)
markCLI("local-endpoint-interfaces", localEndpointInterfacesStr != origLocalEndpointInterfaces)
markCLI("metrics", cfg.MetricsEnabled != origMetrics)
markCLI("otlp", cfg.OTLPEnabled != origOTLP)
markCLI("metrics-admin-addr", cfg.AdminAddr != origAdminAddr)
@@ -533,16 +593,16 @@ func loadNewtConfig() newtpkg.Config {
// Version check (exits process)
if *version {
fmt.Println("Newt version " + newtVersion)
fmt.Println("Newt version " + opts.Version)
os.Exit(0)
}
if *showConfig {
printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr)
printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr, localEndpointInterfacesStr)
os.Exit(0)
}
logger.Info("Newt version %s", newtVersion)
logger.Info("Newt version %s", opts.Version)
// Parse port
if portStr != "" {
@@ -554,6 +614,19 @@ func loadNewtConfig() newtpkg.Config {
}
}
// Parse local endpoint interface allowlist (after flag parsing so CLI takes effect)
if localEndpointInterfacesStr != "" {
var names []string
for _, n := range strings.Split(localEndpointInterfacesStr, ",") {
if t := strings.TrimSpace(n); t != "" {
names = append(names, t)
}
}
cfg.LocalEndpointInterfaces = names
} else {
cfg.LocalEndpointInterfaces = nil
}
// Parse MTU
if mtuStr == "" {
mtuStr = "1280"
@@ -572,16 +645,20 @@ func loadNewtConfig() newtpkg.Config {
cfg.DockerEnforceNetworkValidation = false
}
// Parse durations (after flag.Parse so CLI flags take effect)
// Parse durations (after flag parsing so CLI flags take effect)
cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL")
cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT")
cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT")
return cfg
if err := validateTLSConfig(cfg); err != nil {
return newtpkg.Config{}, err
}
return cfg, nil
}
// printShowConfig prints the resolved configuration and the source of each value
func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr string) {
func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr, localEndpointInterfacesStr string) {
getSource := func(key string) string {
if s, ok := sources[key]; ok && s != "" {
return s
@@ -629,6 +706,7 @@ func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath,
fmt.Printf(" native-main = %v [%s]\n", cfg.UseNativeMainInterface, getSource("native-main"))
fmt.Printf(" interface-main = %s [%s]\n", cfg.NativeMainInterfaceName, getSource("interface-main"))
fmt.Printf(" no-cloud = %v [%s]\n", cfg.NoCloud, getSource("no-cloud"))
fmt.Printf(" local-endpoint-interfaces = %s [%s]\n", mask("local-endpoint-interfaces", localEndpointInterfacesStr), getSource("local-endpoint-interfaces"))
fmt.Println("\nLogging:")
fmt.Printf(" log-level = %s [%s]\n", cfg.LogLevel, getSource("log-level"))
@@ -1,42 +1,67 @@
package main
package newtconfig
import (
"flag"
"os"
"path/filepath"
"testing"
)
// resetFlags allows flag.Parse() to be called again in each test, since
// loadNewtConfig registers flags on the global flag.CommandLine.
func resetFlags(t *testing.T) {
t.Helper()
oldArgs := os.Args
oldCommandLine := flag.CommandLine
t.Cleanup(func() {
os.Args = oldArgs
flag.CommandLine = oldCommandLine
})
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}
func clearNewtEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"PANGOLIN_ENDPOINT", "NEWT_ID", "NEWT_SECRET", "DNS", "LOG_LEVEL",
"MTU", "CONFIG_FILE", "NEWT_PROVISIONING_KEY", "NEWT_NAME",
"DISABLE_SSH", "DISABLE_CLIENTS",
"DISABLE_SSH", "DISABLE_CLIENTS", "SITE_ID", "SITE_SECRET",
} {
t.Setenv(k, "")
}
}
func TestLoadNewtConfig_Defaults(t *testing.T) {
resetFlags(t)
func TestLoadNewtConfig_SiteIDSecretEnvAliases(t *testing.T) {
clearNewtEnv(t)
os.Args = []string{"newt", "--config-file", filepath.Join(t.TempDir(), "missing.json")}
t.Setenv("SITE_ID", "from-site-id")
t.Setenv("SITE_SECRET", "from-site-secret")
cfg := loadNewtConfig()
cfg, err := Load(Options{Args: []string{"--config-file", filepath.Join(t.TempDir(), "missing.json")}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ID != "from-site-id" {
t.Errorf("expected id from SITE_ID, got %q", cfg.ID)
}
if cfg.Secret != "from-site-secret" {
t.Errorf("expected secret from SITE_SECRET, got %q", cfg.Secret)
}
}
func TestLoadNewtConfig_NewtIDSecretWinOverSiteAliases(t *testing.T) {
clearNewtEnv(t)
t.Setenv("SITE_ID", "from-site-id")
t.Setenv("SITE_SECRET", "from-site-secret")
t.Setenv("NEWT_ID", "from-newt-id")
t.Setenv("NEWT_SECRET", "from-newt-secret")
cfg, err := Load(Options{Args: []string{"--config-file", filepath.Join(t.TempDir(), "missing.json")}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ID != "from-newt-id" {
t.Errorf("expected NEWT_ID to win over SITE_ID, got %q", cfg.ID)
}
if cfg.Secret != "from-newt-secret" {
t.Errorf("expected NEWT_SECRET to win over SITE_SECRET, got %q", cfg.Secret)
}
}
func TestLoadNewtConfig_Defaults(t *testing.T) {
clearNewtEnv(t)
cfg, err := Load(Options{Args: []string{"--config-file", filepath.Join(t.TempDir(), "missing.json")}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.DNS != "9.9.9.9" {
t.Errorf("expected default dns, got %q", cfg.DNS)
@@ -50,16 +75,17 @@ func TestLoadNewtConfig_Defaults(t *testing.T) {
}
func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1","mtu":1300,"disableSsh":true}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
os.Args = []string{"newt", "--config-file", configPath}
cfg := loadNewtConfig()
cfg, err := Load(Options{Args: []string{"--config-file", configPath}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.DNS != "1.1.1.1" {
t.Errorf("expected dns from file, got %q", cfg.DNS)
@@ -73,7 +99,6 @@ func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) {
}
func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -81,9 +106,11 @@ func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("DNS", "8.8.4.4")
os.Args = []string{"newt", "--config-file", configPath}
cfg := loadNewtConfig()
cfg, err := Load(Options{Args: []string{"--config-file", configPath}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.DNS != "8.8.4.4" {
t.Errorf("expected env to override file dns, got %q", cfg.DNS)
@@ -91,7 +118,6 @@ func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
}
func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -99,9 +125,11 @@ func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("DNS", "8.8.4.4")
os.Args = []string{"newt", "--config-file", configPath, "--dns", "4.2.2.2"}
cfg := loadNewtConfig()
cfg, err := Load(Options{Args: []string{"--config-file", configPath, "--dns", "4.2.2.2"}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.DNS != "4.2.2.2" {
t.Errorf("expected cli to override env dns, got %q", cfg.DNS)
@@ -109,22 +137,28 @@ func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
}
func TestLoadNewtConfig_TLSClientCAMergesAcrossSources(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
tmpDir := t.TempDir()
caFromFile := filepath.Join(tmpDir, "file-ca.pem")
caFromEnv := filepath.Join(tmpDir, "env-ca.pem")
caFromCLI := filepath.Join(tmpDir, "cli-ca.pem")
for _, ca := range []string{caFromFile, caFromEnv, caFromCLI} {
if err := os.WriteFile(ca, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to write CA file: %v", err)
}
}
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tlsClientCa":["`+caFromFile+`"]}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("TLS_CLIENT_CAS", caFromEnv)
os.Args = []string{"newt", "--config-file", configPath, "--tls-client-ca", caFromCLI}
cfg := loadNewtConfig()
cfg, err := Load(Options{Args: []string{"--config-file", configPath, "--tls-client-ca", caFromCLI}})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
want := map[string]bool{caFromFile: true, caFromEnv: true, caFromCLI: true}
if len(cfg.TLSClientCAs) != len(want) {