Compare commits

..
Author SHA1 Message Date
Owen 46f58dd59f add test for magic packets 2026-08-03 12:13:22 -04:00
Owen 01cb6f39ca add funcs to add and remove ip from interface 2026-07-31 10:34:07 -04:00
Owen 69d3925167 move exit node ping to module 2026-07-30 16:46:30 -04:00
Owen 385dafa857 Support combining certs into one store 2026-07-22 17:21:39 -04:00
Owen f9d57acd3e Add fallback self signed cert so not locked out 2026-07-22 16:50:45 -04:00
17 changed files with 633 additions and 187 deletions
+19 -19
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
@@ -243,14 +243,14 @@ jobs:
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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
+17
View File
@@ -683,6 +683,23 @@ func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes [
}
}
// IsMagicPacket reports whether payload is one of our connectivity-test magic
// packets (a MagicTestRequest or MagicTestResponse). These packets are meant to
// travel directly between physical UDP sockets and must never be encapsulated by
// WireGuard - e.g. if OS routing mistakenly sends one into a WireGuard TUN
// interface (because the destination falls inside a routed tunnel subnet), it
// should be dropped there rather than tunneled, which would otherwise make a
// LAN-local endpoint test falsely appear to succeed over the tunnel.
func IsMagicPacket(payload []byte) bool {
if len(payload) >= MagicTestRequestLen && bytes.HasPrefix(payload, MagicTestRequest) {
return true
}
if len(payload) >= MagicTestResponseLen && bytes.HasPrefix(payload, MagicTestResponse) {
return true
}
return false
}
// handleMagicPacket checks if the packet is a magic test packet and responds if so.
// Returns true if the packet was a magic packet and was handled (should not be passed to WireGuard).
func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool {
+143 -12
View File
@@ -35,10 +35,11 @@ import (
)
type WgConfig struct {
IpAddress string `json:"ipAddress"`
Peers []Peer `json:"peers"`
Targets []Target `json:"targets"`
ChainId string `json:"chainId"`
IpAddress string `json:"ipAddress"`
Peers []Peer `json:"peers"`
Targets []Target `json:"targets"`
Certs []CertData `json:"certs"`
ChainId string `json:"chainId"`
}
type Target struct {
@@ -53,6 +54,23 @@ type Target struct {
HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across
TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination
TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination
TLSCertID string `json:"tlsCertId,omitempty"` // references an entry in the sync message's Certs list instead of inlining TLSCert/TLSKey
}
// CertData is a single shared TLS certificate/key pair, referenced by ID from
// one or more Targets via TLSCertID. Sent once per sync message so that many
// targets backed by the same certificate (e.g. a wildcard cert) don't each
// carry a full copy of the PEM data.
type CertData struct {
ID string `json:"id"`
Cert string `json:"cert"`
Key string `json:"key"`
}
// CertPair holds the resolved PEM certificate/key material for a CertData entry.
type CertPair struct {
Cert string
Key string
}
type PortRange struct {
@@ -122,6 +140,11 @@ type WireGuardService struct {
// connection blocking: when true, all new incoming connections are dropped
blocked atomic.Bool
// certs resolves TLSCertID references on incoming Targets to their PEM
// cert/key material. Replaced wholesale on every full sync.
certs map[string]CertPair
certsMu sync.RWMutex
}
// generateChainId generates a random chain ID for deduplicating round-trip messages.
@@ -196,6 +219,8 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget)
wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget)
wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget)
wsClient.RegisterHandler("newt/certs/add", service.handleAddCerts)
wsClient.RegisterHandler("newt/certs/remove", service.handleRemoveCerts)
return service, nil
}
@@ -544,6 +569,7 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
}
s.config = config
s.SetCerts(config.Certs)
if s.stopGetConfig != nil {
s.stopGetConfig()
@@ -568,6 +594,107 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
logger.Info("Client connectivity setup. Ready to accept connections from clients!")
}
// SetCerts replaces the TLSCertID lookup table used by resolveTLS. The server
// sends the complete set of referenced certs on every full sync, so this is a
// wholesale replacement rather than an incremental merge.
func (s *WireGuardService) SetCerts(certs []CertData) {
m := make(map[string]CertPair, len(certs))
for _, c := range certs {
m[c.ID] = CertPair{Cert: c.Cert, Key: c.Key}
}
s.certsMu.Lock()
s.certs = m
s.certsMu.Unlock()
}
// resolveTLS returns the PEM cert/key to use for target's incoming HTTPS
// termination: target.TLSCertID looked up in the certs table if set, falling
// back to the target's own inline TLSCert/TLSKey otherwise.
func (s *WireGuardService) resolveTLS(target Target) (cert, key string) {
if target.TLSCertID == "" {
return target.TLSCert, target.TLSKey
}
s.certsMu.RLock()
pair, ok := s.certs[target.TLSCertID]
s.certsMu.RUnlock()
if !ok {
logger.Warn("No cert found for tlsCertId %s, falling back to inline cert on target", target.TLSCertID)
return target.TLSCert, target.TLSKey
}
return pair.Cert, pair.Key
}
// AddCerts upserts the given certs into the lookup table used by resolveTLS,
// without discarding any certs already present. Used for incremental cert
// pushes (e.g. after a renewal) outside of a full newt/sync or
// newt/wg/receive-config, which replace the table wholesale via SetCerts.
func (s *WireGuardService) AddCerts(certs []CertData) {
if len(certs) == 0 {
return
}
s.certsMu.Lock()
if s.certs == nil {
s.certs = make(map[string]CertPair, len(certs))
}
for _, c := range certs {
s.certs[c.ID] = CertPair{Cert: c.Cert, Key: c.Key}
}
s.certsMu.Unlock()
}
// RemoveCerts deletes the given cert IDs from the lookup table, e.g. once the
// server knows no target references them anymore.
func (s *WireGuardService) RemoveCerts(ids []string) {
if len(ids) == 0 {
return
}
s.certsMu.Lock()
for _, id := range ids {
delete(s.certs, id)
}
s.certsMu.Unlock()
}
// handleAddCerts processes a "newt/certs/add" message: an array of CertData
// to upsert into the cert lookup table.
func (s *WireGuardService) handleAddCerts(msg websocket.WSMessage) {
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling cert add data: %v", err)
return
}
var certs []CertData
if err := json.Unmarshal(jsonData, &certs); err != nil {
logger.Warn("Error unmarshaling cert add data: %v", err)
return
}
s.AddCerts(certs)
logger.Info("Added %d certs", len(certs))
}
// handleRemoveCerts processes a "newt/certs/remove" message: {ids: [...]}
// naming the cert IDs to drop from the lookup table.
func (s *WireGuardService) handleRemoveCerts(msg websocket.WSMessage) {
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling cert remove data: %v", err)
return
}
var data struct {
IDs []string `json:"ids"`
}
if err := json.Unmarshal(jsonData, &data); err != nil {
logger.Warn("Error unmarshaling cert remove data: %v", err)
return
}
s.RemoveCerts(data.IDs)
logger.Info("Removed %d certs", len(data.IDs))
}
// Sync synchronizes the clients WireGuard peers and targets with the desired state
// received as part of the main newt/sync message.
func (s *WireGuardService) Sync(peers []Peer, targets []Target) {
@@ -680,6 +807,7 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
continue
}
tlsCert, tlsKey := s.resolveTLS(target)
rules = append(rules, netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
@@ -689,8 +817,8 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
})
}
}
@@ -976,6 +1104,7 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
if err != nil {
return fmt.Errorf("invalid CIDR %s: %v", sp, err)
}
tlsCert, tlsKey := s.resolveTLS(target)
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
@@ -985,8 +1114,8 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
@@ -1380,6 +1509,7 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
tlsCert, tlsKey := s.resolveTLS(target)
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
@@ -1389,8 +1519,8 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
@@ -1509,6 +1639,7 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
tlsCert, tlsKey := s.resolveTLS(target)
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
@@ -1518,8 +1649,8 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
+176
View File
@@ -0,0 +1,176 @@
// Package exitnode implements the exit-node ping dance run before
// registering with the server: request the candidate exit nodes, ping each
// one over HTTP, and report the results so the server can pick the best one.
// It is shared between newt and olm, which both register the same way.
package exitnode
import (
"net/http"
"strings"
"time"
"github.com/fosrl/newt/logger"
)
// ExitNodeData is the payload the server sends in response to a
// "*/ping/request" message.
type ExitNodeData struct {
ExitNodes []ExitNode `json:"exitNodes"`
ChainId string `json:"chainId"`
}
// ExitNode is a candidate exit node offered by the server for ping selection.
type ExitNode struct {
ID int `json:"exitNodeId"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
Weight float64 `json:"weight"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
// ExitNodePingResult is the measured latency (or error) for one exit node,
// sent back to the server in the "*/wg/register" message's pingResults field.
type ExitNodePingResult struct {
ExitNodeID int `json:"exitNodeId"`
LatencyMs int64 `json:"latencyMs"`
Weight float64 `json:"weight"`
Error string `json:"error,omitempty"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
// PingExitNodes pings the given exit nodes over HTTP and returns a per-node
// ExitNodePingResult suitable for inclusion in a wg/register message's
// pingResults field, so the server can select the best exit node.
//
// If there's only one exit node, or preferEndpoint names one of them, the
// matching node is returned immediately with LatencyMs 0 and no pinging is
// done. Otherwise every node is pinged pingAttempts times over HTTP GET
// <endpoint>/ping and the average latency of successful attempts is used.
//
// When alreadyConnected is true, a node flagged WasPreviouslyConnected is
// excluded from the results as long as at least one other healthy node is
// available, biasing reconnects toward switching away from a possibly
// degraded node.
func PingExitNodes(exitNodes []ExitNode, preferEndpoint string, alreadyConnected bool) []ExitNodePingResult {
if len(exitNodes) == 0 {
return nil
}
if len(exitNodes) == 1 || preferEndpoint != "" {
selected := exitNodes[0]
if preferEndpoint != "" {
for _, node := range exitNodes {
if node.Endpoint == preferEndpoint {
selected = node
break
}
}
}
logger.Debug("Only one exit node available, using it directly: %s", selected.Endpoint)
return []ExitNodePingResult{
{
ExitNodeID: selected.ID,
LatencyMs: 0,
Weight: selected.Weight,
Error: "",
Name: selected.Name,
Endpoint: selected.Endpoint,
WasPreviouslyConnected: selected.WasPreviouslyConnected,
},
}
}
type nodeResult struct {
Node ExitNode
Latency time.Duration
Err error
}
results := make([]nodeResult, len(exitNodes))
const pingAttempts = 3
for i, node := range exitNodes {
var totalLatency time.Duration
var lastErr error
successes := 0
httpClient := &http.Client{
Timeout: 5 * time.Second,
}
url := node.Endpoint
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
url = "http://" + url
}
if !strings.HasSuffix(url, "/ping") {
url = strings.TrimRight(url, "/") + "/ping"
}
for j := 0; j < pingAttempts; j++ {
start := time.Now()
resp, err := httpClient.Get(url)
latency := time.Since(start)
if err != nil {
lastErr = err
logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err)
continue
}
resp.Body.Close()
totalLatency += latency
successes++
}
var avgLatency time.Duration
if successes > 0 {
avgLatency = totalLatency / time.Duration(successes)
}
if successes == 0 {
results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr}
} else {
results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil}
}
}
var pingResults []ExitNodePingResult
for _, res := range results {
errMsg := ""
if res.Err != nil {
errMsg = res.Err.Error()
}
pingResults = append(pingResults, ExitNodePingResult{
ExitNodeID: res.Node.ID,
LatencyMs: res.Latency.Milliseconds(),
Weight: res.Node.Weight,
Error: errMsg,
Name: res.Node.Name,
Endpoint: res.Node.Endpoint,
WasPreviouslyConnected: res.Node.WasPreviouslyConnected,
})
}
if alreadyConnected {
var filteredPingResults []ExitNodePingResult
previouslyConnectedNodeIdx := -1
for i, res := range pingResults {
if res.WasPreviouslyConnected {
previouslyConnectedNodeIdx = i
}
}
goodNodeCount := 0
for i, res := range pingResults {
if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" {
goodNodeCount++
}
}
if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 {
for i, res := range pingResults {
if i != previouslyConnectedNodeIdx {
filteredPingResults = append(filteredPingResults, res)
}
}
pingResults = filteredPingResults
logger.Info("Excluding previously connected exit node from ping results due to other available nodes")
}
}
return pingResults
}
+71 -1
View File
@@ -3,9 +3,15 @@ package netstack2
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httputil"
@@ -65,6 +71,13 @@ type HTTPHandler struct {
// of the PEM certificate and key. Parsing a keypair is relatively expensive
// and the same cert is likely reused across many connections.
tlsCache sync.Map // map[string]*tls.Config
// fallbackTLSOnce/fallbackTLSCfg hold a lazily-generated self-signed
// certificate used when a rule's configured cert/key fails to parse, so
// that a misconfigured rule degrades to a browser cert warning instead of
// silently dropping every connection.
fallbackTLSOnce sync.Once
fallbackTLSCfg *tls.Config
}
// ---------------------------------------------------------------------------
@@ -262,7 +275,13 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey))
if err != nil {
return nil, fmt.Errorf("failed to parse TLS keypair: %w", err)
// A misconfigured rule (bad/missing PEM data) must not take the whole
// connection down: fall back to a self-signed cert so the handshake
// still completes and the request reaches handleRequest, which routes
// independently of the cert. Clients will see a cert warning instead
// of a silent connection reset.
logger.Warn("HTTP handler: falling back to self-signed cert for rule (invalid configured keypair): %v", err)
return h.getFallbackTLSConfig(), nil
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
@@ -273,6 +292,57 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
return actual.(*tls.Config), nil
}
// getFallbackTLSConfig returns a *tls.Config backed by a self-signed
// certificate, generated once and reused for the lifetime of the handler.
func (h *HTTPHandler) getFallbackTLSConfig() *tls.Config {
h.fallbackTLSOnce.Do(func() {
cert, err := generateSelfSignedCert()
if err != nil {
// Generation of an in-memory self-signed cert has no external
// dependencies and should never fail; if it somehow does, there
// is no sensible fallback left, so surface it loudly.
logger.Error("HTTP handler: failed to generate fallback self-signed cert: %v", err)
return
}
h.fallbackTLSCfg = &tls.Config{Certificates: []tls.Certificate{cert}}
})
return h.fallbackTLSCfg
}
// generateSelfSignedCert creates a fresh, in-memory self-signed TLS
// certificate/key pair valid for one year, used as a fallback when a rule's
// configured certificate cannot be parsed.
func generateSelfSignedCert() (tls.Certificate, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{CommonName: "newt-fallback"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
}
return tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: priv,
}, nil
}
// getProxy returns a cached *httputil.ReverseProxy for the given target,
// creating one on first use. Reusing the proxy preserves its http.Transport
// connection pool, avoiding repeated TCP/TLS handshakes to the downstream.
+92
View File
@@ -167,3 +167,95 @@ func configureLinux(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
return nil
}
// AddSecondaryAddress adds an additional IP address (given as CIDR, e.g. "10.10.0.5/32")
// to an already-configured interface. It also records the address in the shared
// NetworkSettings (see AddIPv4Address) so mobile (iOS/Android) packet-tunnel providers
// pick it up on their next settings poll - those platforms have no OS-level interface
// to configure directly, so this is the only way they learn about the address.
func AddSecondaryAddress(interfaceName string, addr string) error {
ip, ipNet, err := net.ParseCIDR(addr)
if err != nil {
return fmt.Errorf("invalid IP address: %v", err)
}
mask := net.IP(ipNet.Mask).String()
AddIPv4Address(ip.String(), mask)
if interfaceName == "" {
return nil
}
switch runtime.GOOS {
case "linux":
return configureLinux(interfaceName, ip, ipNet)
case "darwin":
return configureDarwin(interfaceName, ip, ipNet)
case "windows":
return configureWindows(interfaceName, ip, ipNet)
default:
return nil
}
}
// RemoveSecondaryAddress removes an IP address (given as CIDR) previously added with
// AddSecondaryAddress, including from the shared NetworkSettings used by mobile
// packet-tunnel providers.
func RemoveSecondaryAddress(interfaceName string, addr string) error {
ip, ipNet, err := net.ParseCIDR(addr)
if err != nil {
return fmt.Errorf("invalid IP address: %v", err)
}
RemoveIPv4Address(ip.String())
if interfaceName == "" {
return nil
}
switch runtime.GOOS {
case "linux":
return removeLinuxAddress(interfaceName, ip, ipNet)
case "darwin":
return removeDarwinAddress(interfaceName, ip, ipNet)
case "windows":
return removeWindowsAddress(interfaceName, ip, ipNet)
default:
return nil
}
}
func removeLinuxAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
link, err := netlink.LinkByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
addr := &netlink.Addr{
IPNet: &net.IPNet{
IP: ip,
Mask: ipNet.Mask,
},
}
if err := netlink.AddrDel(link, addr); err != nil {
return fmt.Errorf("failed to remove IP address: %v", err)
}
return nil
}
func removeDarwinAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
prefix, _ := ipNet.Mask.Size()
ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix)
cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, "-alias")
logger.Info("Running command: %v", cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ifconfig command failed: %v, output: %s", err, out)
}
return nil
}
+4
View File
@@ -10,3 +10,7 @@ import (
func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
return fmt.Errorf("configureWindows called on non-Windows platform")
}
func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
return fmt.Errorf("removeWindowsAddress called on non-Windows platform")
}
+32
View File
@@ -61,3 +61,35 @@ func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
return nil
}
func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err)
}
maskBits, _ := ipNet.Mask.Size()
var addr netip.Addr
if ip4 := ip.To4(); ip4 != nil {
addr, _ = netip.AddrFromSlice(ip4)
} else {
addr, _ = netip.AddrFromSlice(ip)
}
if !addr.IsValid() {
return fmt.Errorf("failed to convert IP address")
}
prefix := netip.PrefixFrom(addr, maskBits)
logger.Info("Removing IP address %s from interface %s", prefix.String(), interfaceName)
if err := luid.DeleteIPAddress(prefix); err != nil {
return fmt.Errorf("failed to remove IP address: %v", err)
}
return nil
}
+39
View File
@@ -81,6 +81,45 @@ func SetIPv4Settings(addresses []string, subnetMasks []string) {
logger.Info("Set IPv4 addresses: %v, subnet masks: %v", addresses, subnetMasks)
}
// AddIPv4Address appends an additional IPv4 address/subnet mask pair to the
// tunnel's network settings. This is how a secondary interface address gets
// exposed to mobile (iOS/Android) packet-tunnel providers, which read the
// full IPv4Addresses/IPv4SubnetMasks arrays (not just the first entry) and
// re-apply them on every settings poll.
func AddIPv4Address(address string, subnetMask string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
for _, a := range networkSettings.IPv4Addresses {
if a == address {
logger.Info("IPv4 address already exists: %s", address)
return
}
}
networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses, address)
networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks, subnetMask)
incrementor++
logger.Info("Added IPv4 address: %s/%s", address, subnetMask)
}
// RemoveIPv4Address removes a previously added secondary IPv4 address.
func RemoveIPv4Address(address string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
for i, a := range networkSettings.IPv4Addresses {
if a == address {
networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses[:i], networkSettings.IPv4Addresses[i+1:]...)
networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks[:i], networkSettings.IPv4SubnetMasks[i+1:]...)
incrementor++
logger.Info("Removed IPv4 address: %s", address)
return
}
}
logger.Info("IPv4 address not found for removal: %s", address)
}
// SetIPv4IncludedRoutes sets the included IPv4 routes
func SetIPv4IncludedRoutes(routes []IPv4Route) {
networkSettingsMutex.Lock()
+1
View File
@@ -144,6 +144,7 @@ func (n *Newt) handleSync(msg websocket.WSMessage) {
// Sync clients WireGuard peers and targets, if clients are set up
if n.wgService != nil {
n.wgService.SetCerts(syncData.Certs)
n.wgService.Sync(syncData.Peers, syncData.ClientTargets)
}
+2 -124
View File
@@ -10,13 +10,13 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/fosrl/newt/authdaemon"
"github.com/fosrl/newt/browsergateway"
"github.com/fosrl/newt/docker"
"github.com/fosrl/newt/exitnode"
"github.com/fosrl/newt/healthcheck"
"github.com/fosrl/newt/internal/state"
"github.com/fosrl/newt/internal/telemetry"
@@ -140,129 +140,7 @@ func (n *Newt) registerHandlers(ctx context.Context) {
return
}
if len(exitNodes) == 1 || n.config.PreferEndpoint != "" {
logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint)
if n.config.PreferEndpoint != "" {
for _, node := range exitNodes {
if node.Endpoint == n.config.PreferEndpoint {
exitNodes[0] = node
break
}
}
}
pingResults := []ExitNodePingResult{
{
ExitNodeID: exitNodes[0].ID,
LatencyMs: 0,
Weight: exitNodes[0].Weight,
Error: "",
Name: exitNodes[0].Name,
Endpoint: exitNodes[0].Endpoint,
WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected,
},
}
chainId := generateChainId()
n.pendingRegisterChainId = chainId
n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{
"publicKey": n.publicKey.String(),
"pingResults": pingResults,
"newtVersion": n.config.Version,
"chainId": chainId,
}, 2*time.Second)
return
}
type nodeResult struct {
Node ExitNode
Latency time.Duration
Err error
}
results := make([]nodeResult, len(exitNodes))
const pingAttempts = 3
for i, node := range exitNodes {
var totalLatency time.Duration
var lastErr error
successes := 0
httpClient := &http.Client{
Timeout: 5 * time.Second,
}
url := node.Endpoint
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
url = "http://" + url
}
if !strings.HasSuffix(url, "/ping") {
url = strings.TrimRight(url, "/") + "/ping"
}
for j := 0; j < pingAttempts; j++ {
start := time.Now()
resp, err := httpClient.Get(url)
latency := time.Since(start)
if err != nil {
lastErr = err
logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err)
continue
}
resp.Body.Close()
totalLatency += latency
successes++
}
var avgLatency time.Duration
if successes > 0 {
avgLatency = totalLatency / time.Duration(successes)
}
if successes == 0 {
results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr}
} else {
results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil}
}
}
var pingResults []ExitNodePingResult
for _, res := range results {
errMsg := ""
if res.Err != nil {
errMsg = res.Err.Error()
}
pingResults = append(pingResults, ExitNodePingResult{
ExitNodeID: res.Node.ID,
LatencyMs: res.Latency.Milliseconds(),
Weight: res.Node.Weight,
Error: errMsg,
Name: res.Node.Name,
Endpoint: res.Node.Endpoint,
WasPreviouslyConnected: res.Node.WasPreviouslyConnected,
})
}
if n.connected {
var filteredPingResults []ExitNodePingResult
previouslyConnectedNodeIdx := -1
for i, res := range pingResults {
if res.WasPreviouslyConnected {
previouslyConnectedNodeIdx = i
}
}
goodNodeCount := 0
for i, res := range pingResults {
if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" {
goodNodeCount++
}
}
if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 {
for i, res := range pingResults {
if i != previouslyConnectedNodeIdx {
filteredPingResults = append(filteredPingResults, res)
}
}
pingResults = filteredPingResults
logger.Info("Excluding previously connected exit node from ping results due to other available nodes")
}
}
pingResults := exitnode.PingExitNodes(exitNodes, n.config.PreferEndpoint, n.connected)
chainId := generateChainId()
n.pendingRegisterChainId = chainId
+10 -22
View File
@@ -2,6 +2,7 @@ package newt
import (
wgclients "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/exitnode"
"github.com/fosrl/newt/healthcheck"
)
@@ -35,28 +36,14 @@ type TargetData struct {
Targets []string `json:"targets"`
}
type ExitNodeData struct {
ExitNodes []ExitNode `json:"exitNodes"`
ChainId string `json:"chainId"`
}
type ExitNode struct {
ID int `json:"exitNodeId"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
Weight float64 `json:"weight"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
type ExitNodePingResult struct {
ExitNodeID int `json:"exitNodeId"`
LatencyMs int64 `json:"latencyMs"`
Weight float64 `json:"weight"`
Error string `json:"error,omitempty"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
// ExitNodeData, ExitNode and ExitNodePingResult are aliases for the shared
// exit-node ping dance types in package exitnode, kept here so existing code
// in this package can keep referring to them unqualified.
type (
ExitNodeData = exitnode.ExitNodeData
ExitNode = exitnode.ExitNode
ExitNodePingResult = exitnode.ExitNodePingResult
)
type BlueprintResult struct {
Success bool `json:"success"`
@@ -70,5 +57,6 @@ type SyncData struct {
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
Peers []wgclients.Peer `json:"peers"`
ClientTargets []wgclients.Target `json:"clientTargets"`
Certs []wgclients.CertData `json:"certs"`
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
}
+20 -2
View File
@@ -28,6 +28,15 @@ import (
"go.opentelemetry.io/otel"
)
// writeDeadline bounds how long a websocket write may block before it is
// treated as a failure. Without this, a write to a TCP connection whose
// underlying network interface has disappeared (e.g. laptop sleep/resume,
// Wi-Fi roam) can sit buffered in the kernel for minutes without erroring.
// This matters even with the read-deadline/pong machinery below: if the
// WriteJSON call in sendPing blocks, execution never reaches the
// WriteControl ping that would otherwise trigger that read-side detection.
const writeDeadline = 10 * time.Second
type Client struct {
conn *websocket.Conn
config *Config
@@ -257,6 +266,9 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
c.writeMux.Lock()
defer c.writeMux.Unlock()
if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil {
return err
}
if err := c.conn.WriteJSON(msg); err != nil {
return err
}
@@ -277,6 +289,9 @@ func (c *Client) SendMessageNoLog(messageType string, data interface{}) error {
c.writeMux.Lock()
defer c.writeMux.Unlock()
if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil {
return err
}
if err := c.conn.WriteJSON(msg); err != nil {
return err
}
@@ -760,14 +775,17 @@ func (c *Client) sendPing() {
c.writeMux.Unlock()
return
}
err := c.conn.WriteJSON(pingMsg)
err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err == nil {
err = c.conn.WriteJSON(pingMsg)
}
if err == nil {
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
// Protocol-level ping: a standards-compliant server replies with a PONG,
// which refreshes the read deadline. This is what lets us notice a
// half-open connection where writes still succeed (buffered) but the
// peer is gone.
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline))
}
c.writeMux.Unlock()