Compare commits

...

11 Commits

Author SHA1 Message Date
Owen
092535441e Pass the new data down from the websocket 2026-04-09 16:13:19 -04:00
Owen
5848c8d4b4 Adjust to use data saved inside of the subnet rule 2026-04-09 16:04:11 -04:00
Owen
47c646bc33 Basic http is working 2026-04-09 11:43:26 -04:00
Owen Schwartz
7e1e3408d5 Merge pull request #302 from LaurenceJJones/fix/config-file-provision-save
fix: allow empty config file bootstrap before provisioning
2026-04-08 21:58:07 -04:00
Laurence
d7c3c38d24 fix: allow empty config file bootstrap before provisioning
Treat an empty CONFIG_FILE as initial state instead of failing JSON parse, so provisioning can proceed and credentials can be saved. Ref: fosrl/pangolin#2812
2026-04-08 14:13:13 +01:00
Owen
27e471942e Add CODEOWNERS 2026-04-07 11:34:18 -04:00
Owen
184bfb12d6 Delete bad bp 2026-04-03 17:36:48 -04:00
Owen
2e02c9b7a9 Remove files 2026-04-03 16:49:09 -04:00
Owen
f4d071fe27 Add provisioning blueprint file 2026-04-02 21:39:59 -04:00
Owen
8d82460a76 Send health checks to the server on reconnect 2026-03-31 17:06:07 -07:00
Owen
5208117c56 Add name to provisioning 2026-03-30 17:18:22 -07:00
16 changed files with 582 additions and 107 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

@@ -1,37 +0,0 @@
resources:
resource-nice-id:
name: this is my resource
protocol: http
full-domain: level1.test3.example.com
host-header: example.com
tls-server-name: example.com
auth:
pincode: 123456
password: sadfasdfadsf
sso-enabled: true
sso-roles:
- Member
sso-users:
- owen@pangolin.net
whitelist-users:
- owen@pangolin.net
targets:
# - site: glossy-plains-viscacha-rat
- hostname: localhost
method: http
port: 8000
healthcheck:
port: 8000
hostname: localhost
# - site: glossy-plains-viscacha-rat
- hostname: localhost
method: http
port: 8001
resource-nice-id2:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
# - site: glossy-plains-viscacha-rat
- hostname: localhost
port: 3000

View File

@@ -47,6 +47,10 @@ type Target struct {
DisableIcmp bool `json:"disableIcmp,omitempty"` DisableIcmp bool `json:"disableIcmp,omitempty"`
PortRange []PortRange `json:"portRange,omitempty"` PortRange []PortRange `json:"portRange,omitempty"`
ResourceId int `json:"resourceId,omitempty"` ResourceId int `json:"resourceId,omitempty"`
Protocol string `json:"protocol,omitempty"` // for now practicably either http or https
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
} }
type PortRange struct { type PortRange struct {
@@ -697,7 +701,18 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
}) })
} }
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix)
} }
} }
@@ -955,7 +970,18 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid CIDR %s: %v", sp, err) return fmt.Errorf("invalid CIDR %s: %v", sp, err)
} }
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
} }
} }
@@ -1348,7 +1374,18 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
logger.Info("Invalid CIDR %s: %v", sp, err) logger.Info("Invalid CIDR %s: %v", sp, err)
continue continue
} }
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
} }
} }
@@ -1466,7 +1503,18 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
logger.Info("Invalid CIDR %s: %v", sp, err) logger.Info("Invalid CIDR %s: %v", sp, err)
continue continue
} }
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
} }
} }

View File

@@ -540,12 +540,12 @@ func interpolateBlueprint(data []byte) []byte {
}) })
} }
func sendBlueprint(client *websocket.Client) error { func sendBlueprint(client *websocket.Client, file string) error {
if blueprintFile == "" { if file == "" {
return nil return nil
} }
// try to read the blueprint file // try to read the blueprint file
blueprintData, err := os.ReadFile(blueprintFile) blueprintData, err := os.ReadFile(file)
if err != nil { if err != nil {
logger.Error("Failed to read blueprint file: %v", err) logger.Error("Failed to read blueprint file: %v", err)
} else { } else {

View File

@@ -1,4 +0,0 @@
{
"endpoint": "http://you.fosrl.io",
"provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx"
}

View File

@@ -1,4 +0,0 @@
{
"endpoint": "http://you.fosrl.io",
"provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx"
}

45
main.go
View File

@@ -156,6 +156,7 @@ var (
metricsAsyncBytes bool metricsAsyncBytes bool
pprofEnabled bool pprofEnabled bool
blueprintFile string blueprintFile string
provisioningBlueprintFile string
noCloud bool noCloud bool
// New mTLS configuration variables // New mTLS configuration variables
@@ -169,6 +170,9 @@ var (
// Provisioning key exchanged once for a permanent newt ID + secret // Provisioning key exchanged once for a permanent newt ID + secret
provisioningKey string provisioningKey string
// Optional name for the site created during provisioning
newtName string
// Path to config file (overrides CONFIG_FILE env var and default location) // Path to config file (overrides CONFIG_FILE env var and default location)
configFile string configFile string
) )
@@ -281,9 +285,11 @@ func runNewtMain(ctx context.Context) {
tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT") tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT")
} }
blueprintFile = os.Getenv("BLUEPRINT_FILE") blueprintFile = os.Getenv("BLUEPRINT_FILE")
provisioningBlueprintFile = os.Getenv("PROVISIONING_BLUEPRINT_FILE")
noCloudEnv := os.Getenv("NO_CLOUD") noCloudEnv := os.Getenv("NO_CLOUD")
noCloud = noCloudEnv == "true" noCloud = noCloudEnv == "true"
provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY") provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY")
newtName = os.Getenv("NEWT_NAME")
configFile = os.Getenv("CONFIG_FILE") configFile = os.Getenv("CONFIG_FILE")
if endpoint == "" { if endpoint == "" {
@@ -336,6 +342,9 @@ func runNewtMain(ctx context.Context) {
if provisioningKey == "" { if provisioningKey == "" {
flag.StringVar(&provisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") flag.StringVar(&provisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server")
} }
if newtName == "" {
flag.StringVar(&newtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
}
if configFile == "" { if configFile == "" {
flag.StringVar(&configFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)") flag.StringVar(&configFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)")
} }
@@ -386,6 +395,9 @@ func runNewtMain(ctx context.Context) {
if blueprintFile == "" { if blueprintFile == "" {
flag.StringVar(&blueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)") flag.StringVar(&blueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)")
} }
if provisioningBlueprintFile == "" {
flag.StringVar(&provisioningBlueprintFile, "provisioning-blueprint-file", "", "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
}
if noCloudEnv == "" { if noCloudEnv == "" {
flag.BoolVar(&noCloud, "no-cloud", false, "Disable cloud failover") flag.BoolVar(&noCloud, "no-cloud", false, "Disable cloud failover")
} }
@@ -623,6 +635,9 @@ func runNewtMain(ctx context.Context) {
if provisioningKey != "" && client.GetConfig().ProvisioningKey == "" { if provisioningKey != "" && client.GetConfig().ProvisioningKey == "" {
client.GetConfig().ProvisioningKey = provisioningKey client.GetConfig().ProvisioningKey = provisioningKey
} }
if newtName != "" && client.GetConfig().Name == "" {
client.GetConfig().Name = newtName
}
endpoint = client.GetConfig().Endpoint // Update endpoint from config endpoint = client.GetConfig().Endpoint // Update endpoint from config
id = client.GetConfig().ID // Update ID from config id = client.GetConfig().ID // Update ID from config
@@ -1810,6 +1825,34 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
} else { } else {
logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT")
} }
sendBlueprint(client, blueprintFile)
if client.WasJustProvisioned() {
logger.Info("Provisioning detected sending provisioning blueprint")
sendBlueprint(client, provisioningBlueprintFile)
}
} else {
// Resend current health check status for all targets in case the server
// missed updates while newt was disconnected.
targets := healthMonitor.GetTargets()
if len(targets) > 0 {
healthStatuses := make(map[int]interface{})
for id, target := range targets {
healthStatuses[id] = map[string]interface{}{
"status": target.Status.String(),
"lastCheck": target.LastCheck.Format(time.RFC3339),
"checkCount": target.CheckCount,
"lastError": target.LastError,
"config": target.Config,
}
}
logger.Debug("Reconnected: resending health check status for %d targets", len(healthStatuses))
if err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{
"targets": healthStatuses,
}); err != nil {
logger.Error("Failed to resend health check status on reconnect: %v", err)
}
}
} }
// Send registration message to the server for backward compatibility // Send registration message to the server for backward compatibility
@@ -1822,8 +1865,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
"chainId": bcChainId, "chainId": bcChainId,
}) })
sendBlueprint(client)
if err != nil { if err != nil {
logger.Error("Failed to send registration message: %v", err) logger.Error("Failed to send registration message: %v", err)
return err return err

View File

@@ -137,14 +137,31 @@ func (h *TCPHandler) InstallTCPHandler() error {
// handleTCPConn handles a TCP connection by proxying it to the actual target // handleTCPConn handles a TCP connection by proxying it to the actual target
func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) { func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) {
defer netstackConn.Close() // Extract source and target address from the connection ID first so they
// are available for HTTP routing before any defer is set up.
// Extract source and target address from the connection ID
srcIP := id.RemoteAddress.String() srcIP := id.RemoteAddress.String()
srcPort := id.RemotePort srcPort := id.RemotePort
dstIP := id.LocalAddress.String() dstIP := id.LocalAddress.String()
dstPort := id.LocalPort dstPort := id.LocalPort
// For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has
// Protocol configured, hand the connection off to the HTTP handler which
// takes full ownership of the lifecycle (the defer close must not be
// installed before this point).
if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil {
srcAddr, _ := netip.ParseAddr(srcIP)
dstAddr, _ := netip.ParseAddr(dstIP)
rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber)
if rule != nil && rule.Protocol != "" {
logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)",
srcIP, srcPort, dstIP, dstPort, rule.Protocol)
h.proxyHandler.httpHandler.HandleConn(netstackConn, rule)
return
}
}
defer netstackConn.Close()
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Check if there's a destination rewrite for this connection (e.g., localhost targets) // Check if there's a destination rewrite for this connection (e.g., localhost targets)

318
netstack2/http_handler.go Normal file
View File

@@ -0,0 +1,318 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
*/
package netstack2
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// ---------------------------------------------------------------------------
// HTTPTarget
// ---------------------------------------------------------------------------
// HTTPTarget describes a single downstream HTTP or HTTPS service that the
// proxy should forward requests to.
type HTTPTarget struct {
DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service
DestPort uint16 `json:"destPort"` // TCP port of the downstream service
UseHTTPS bool `json:"useHttps"` // When true the outbound leg uses HTTPS
}
// ---------------------------------------------------------------------------
// HTTPHandler
// ---------------------------------------------------------------------------
// HTTPHandler intercepts TCP connections from the netstack forwarder on ports
// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request
// to downstream targets specified by the matching SubnetRule.
//
// HTTP and raw TCP are fully separate: a connection is only routed here when
// its SubnetRule has Protocol set ("http" or "https"). All other connections
// on those ports fall through to the normal raw-TCP path.
//
// Incoming TLS termination (Protocol == "https") is performed per-connection
// using the certificate and key stored in the rule, so different subnet rules
// can present different certificates without sharing any state.
//
// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS
// independently of the incoming protocol.
type HTTPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
listener *chanListener
server *http.Server
// proxyCache holds pre-built *httputil.ReverseProxy values keyed by the
// canonical target URL string ("scheme://host:port"). Building a proxy is
// cheap, but reusing one preserves the underlying http.Transport connection
// pool, which matters for throughput.
proxyCache sync.Map // map[string]*httputil.ReverseProxy
// tlsCache holds pre-parsed *tls.Config values keyed by the concatenation
// 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
}
// ---------------------------------------------------------------------------
// chanListener net.Listener backed by a channel
// ---------------------------------------------------------------------------
// chanListener implements net.Listener by receiving net.Conn values over a
// buffered channel. This lets the netstack TCP forwarder hand off connections
// directly to a running http.Server without any real OS socket.
type chanListener struct {
connCh chan net.Conn
closed chan struct{}
once sync.Once
}
func newChanListener() *chanListener {
return &chanListener{
connCh: make(chan net.Conn, 128),
closed: make(chan struct{}),
}
}
// Accept blocks until a connection is available or the listener is closed.
func (l *chanListener) Accept() (net.Conn, error) {
select {
case conn, ok := <-l.connCh:
if !ok {
return nil, net.ErrClosed
}
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
// Close shuts down the listener; subsequent Accept calls return net.ErrClosed.
func (l *chanListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
// Addr returns a placeholder address (the listener has no real OS socket).
func (l *chanListener) Addr() net.Addr {
return &net.TCPAddr{}
}
// send delivers conn to the listener. Returns false if the listener is already
// closed, in which case the caller is responsible for closing conn.
func (l *chanListener) send(conn net.Conn) bool {
select {
case l.connCh <- conn:
return true
case <-l.closed:
return false
}
}
// ---------------------------------------------------------------------------
// httpConnCtx conn wrapper that carries a SubnetRule through the listener
// ---------------------------------------------------------------------------
// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed
// through the chanListener into the http.Server's ConnContext callback,
// making it available to request handlers via the request context.
type httpConnCtx struct {
net.Conn
rule *SubnetRule
}
// connCtxKey is the unexported context key used to store a *SubnetRule on the
// per-connection context created by http.Server.ConnContext.
type connCtxKey struct{}
// ---------------------------------------------------------------------------
// Constructor and lifecycle
// ---------------------------------------------------------------------------
// NewHTTPHandler creates an HTTPHandler attached to the given stack and
// ProxyHandler. Call Start to begin serving connections.
func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler {
return &HTTPHandler{
stack: s,
proxyHandler: ph,
}
}
// Start launches the internal http.Server that services connections delivered
// via HandleConn. The server runs for the lifetime of the HTTPHandler; call
// Close to stop it.
func (h *HTTPHandler) Start() error {
h.listener = newChanListener()
h.server = &http.Server{
Handler: http.HandlerFunc(h.handleRequest),
// ConnContext runs once per accepted connection and attaches the
// SubnetRule carried by httpConnCtx to the connection's context so
// that handleRequest can retrieve it without any global state.
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
if cc, ok := c.(*httpConnCtx); ok {
return context.WithValue(ctx, connCtxKey{}, cc.rule)
}
return ctx
},
}
go func() {
if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP handler: server exited unexpectedly: %v", err)
}
}()
logger.Info("HTTP handler: ready — routing determined per SubnetRule on ports 80/443")
return nil
}
// HandleConn accepts a TCP connection from the netstack forwarder together
// with the SubnetRule that matched it. The HTTP handler takes full ownership
// of the connection's lifecycle; the caller must NOT close conn after this call.
//
// When rule.Protocol is "https", TLS termination is performed on conn using
// the certificate and key stored in rule.TLSCert and rule.TLSKey before the
// connection is passed to the HTTP server. The HTTP server itself is always
// plain-HTTP; TLS is fully unwrapped at this layer.
func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) {
var effectiveConn net.Conn = conn
if rule.Protocol == "https" {
tlsCfg, err := h.getTLSConfig(rule)
if err != nil {
logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v",
conn.RemoteAddr(), err)
conn.Close()
return
}
// tls.Server wraps the raw conn; the TLS handshake is deferred until
// the first Read, which the http.Server will trigger naturally.
effectiveConn = tls.Server(conn, tlsCfg)
}
wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule}
if !h.listener.send(wrapped) {
// Listener is already closed — clean up the orphaned connection.
effectiveConn.Close()
}
}
// Close gracefully shuts down the HTTP server and the underlying channel
// listener, causing the goroutine started in Start to exit.
func (h *HTTPHandler) Close() error {
if h.server != nil {
if err := h.server.Close(); err != nil {
return err
}
}
if h.listener != nil {
h.listener.Close()
}
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a
// cache to avoid re-parsing the same keypair on every connection.
// The cache key is the concatenation of the PEM cert and key strings, so
// different rules that happen to share the same material hit the same entry.
func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
cacheKey := rule.TLSCert + "|" + rule.TLSKey
if v, ok := h.tlsCache.Load(cacheKey); ok {
return v.(*tls.Config), nil
}
cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey))
if err != nil {
return nil, fmt.Errorf("failed to parse TLS keypair: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
}
// LoadOrStore is safe under concurrent calls: if two goroutines race here
// both will produce a valid config; the loser's work is discarded.
actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg)
return actual.(*tls.Config), 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.
func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
scheme := "http"
if target.UseHTTPS {
scheme = "https"
}
cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort)
if v, ok := h.proxyCache.Load(cacheKey); ok {
return v.(*httputil.ReverseProxy)
}
targetURL := &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort),
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
if target.UseHTTPS {
// Allow self-signed certificates on downstream HTTPS targets.
proxy.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration
},
}
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("HTTP handler: upstream error (%s %s -> %s): %v",
r.Method, r.URL.RequestURI(), cacheKey, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy)
return actual.(*httputil.ReverseProxy)
}
// handleRequest is the http.Handler entry point. It retrieves the SubnetRule
// attached to the connection by ConnContext, selects the first configured
// downstream target, and forwards the request via the cached ReverseProxy.
//
// TODO: add host/path-based routing across multiple HTTPTargets once the
// configuration model evolves beyond a single target per rule.
func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule)
if rule == nil || len(rule.HTTPTargets) == 0 {
logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI())
http.Error(w, "no targets configured", http.StatusBadGateway)
return
}
target := rule.HTTPTargets[0]
scheme := "http"
if target.UseHTTPS {
scheme = "https"
}
logger.Info("HTTP handler: %s %s -> %s://%s:%d",
r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort)
h.getProxy(target).ServeHTTP(w, r)
}

View File

@@ -53,6 +53,14 @@ type SubnetRule struct {
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
PortRanges []PortRange // empty slice means all ports allowed PortRanges []PortRange // empty slice means all ports allowed
ResourceId int // Optional resource ID from the server for access logging ResourceId int // Optional resource ID from the server for access logging
// HTTP proxy configuration (optional).
// When Protocol is non-empty the TCP connection is handled by HTTPHandler
// instead of the raw TCP forwarder.
Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol
HTTPTargets []HTTPTarget // downstream services to proxy requests to
TLSCert string // PEM-encoded certificate for incoming HTTPS termination
TLSKey string // PEM-encoded private key for incoming HTTPS termination
} }
// GetAllRules returns a copy of all subnet rules // GetAllRules returns a copy of all subnet rules
@@ -114,6 +122,7 @@ type ProxyHandler struct {
tcpHandler *TCPHandler tcpHandler *TCPHandler
udpHandler *UDPHandler udpHandler *UDPHandler
icmpHandler *ICMPHandler icmpHandler *ICMPHandler
httpHandler *HTTPHandler
subnetLookup *SubnetLookup subnetLookup *SubnetLookup
natTable map[connKey]*natState natTable map[connKey]*natState
reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT
@@ -164,12 +173,21 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
}), }),
} }
// Initialize TCP handler if enabled // Initialize TCP handler if enabled. The HTTP handler piggybacks on the
// TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for
// ports 80/443 and routes matching connections to the HTTP handler, so
// the HTTP handler is always initialised alongside TCP.
if options.EnableTCP { if options.EnableTCP {
handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler) handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler)
if err := handler.tcpHandler.InstallTCPHandler(); err != nil { if err := handler.tcpHandler.InstallTCPHandler(); err != nil {
return nil, fmt.Errorf("failed to install TCP handler: %v", err) return nil, fmt.Errorf("failed to install TCP handler: %v", err)
} }
handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler)
if err := handler.httpHandler.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP handler: %v", err)
}
logger.Debug("ProxyHandler: HTTP handler enabled")
} }
// Initialize UDP handler if enabled // Initialize UDP handler if enabled
@@ -208,16 +226,14 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
return handler, nil return handler, nil
} }
// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler // AddSubnetRule adds a subnet rule to the proxy handler.
// sourcePrefix: The IP prefix of the peer sending the data // HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
// destPrefix: The IP prefix of the destination // rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
// If portRanges is nil or empty, all ports are allowed for this subnet
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) {
if p == nil || !p.enabled { if p == nil || !p.enabled {
return return
} }
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) p.subnetLookup.AddSubnet(rule)
} }
// RemoveSubnetRule removes a subnet from the proxy handler // RemoveSubnetRule removes a subnet from the proxy handler
@@ -794,6 +810,11 @@ func (p *ProxyHandler) Close() error {
p.accessLogger.Close() p.accessLogger.Close()
} }
// Shut down HTTP handler
if p.httpHandler != nil {
p.httpHandler.Close()
}
// Close ICMP replies channel // Close ICMP replies channel
if p.icmpReplies != nil { if p.icmpReplies != nil {
close(p.icmpReplies) close(p.icmpReplies)

View File

@@ -44,24 +44,18 @@ func prefixEqual(a, b netip.Prefix) bool {
return a.Masked() == b.Masked() return a.Masked() == b.Masked()
} }
// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions // AddSubnet adds a subnet rule to the lookup table.
// If portRanges is nil or empty, all ports are allowed for this subnet // If rule.PortRanges is nil or empty, all ports are allowed.
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") // rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com").
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { // HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey.
func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
sl.mu.Lock() sl.mu.Lock()
defer sl.mu.Unlock() defer sl.mu.Unlock()
rule := &SubnetRule{ rulePtr := &rule
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
DisableIcmp: disableIcmp,
RewriteTo: rewriteTo,
PortRanges: portRanges,
ResourceId: resourceId,
}
// Canonicalize source prefix to handle host bits correctly // Canonicalize source prefix to handle host bits correctly
canonicalSourcePrefix := sourcePrefix.Masked() canonicalSourcePrefix := rule.SourcePrefix.Masked()
// Get or create destination trie for this source prefix // Get or create destination trie for this source prefix
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix) destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
@@ -76,12 +70,12 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite
// Canonicalize destination prefix to handle host bits correctly // Canonicalize destination prefix to handle host bits correctly
// BART masks prefixes internally, so we need to match that behavior in our bookkeeping // BART masks prefixes internally, so we need to match that behavior in our bookkeeping
canonicalDestPrefix := destPrefix.Masked() canonicalDestPrefix := rule.DestPrefix.Masked()
// Add rule to destination trie // Add rule to destination trie
// Original behavior: overwrite if same (sourcePrefix, destPrefix) exists // Original behavior: overwrite if same (sourcePrefix, destPrefix) exists
// Store as single-element slice to match original overwrite behavior // Store as single-element slice to match original overwrite behavior
destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rule}) destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr})
// Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one // Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one
// Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24 // Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24
@@ -91,7 +85,7 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite
newRules = append(newRules, r) newRules = append(newRules, r)
} }
} }
newRules = append(newRules, rule) newRules = append(newRules, rulePtr)
destTriePtr.rules = newRules destTriePtr.rules = newRules
} }

View File

@@ -351,13 +351,13 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) {
return net.DialUDP(laddr, nil) return net.DialUDP(laddr, nil)
} }
// AddProxySubnetRule adds a subnet rule to the proxy handler // AddProxySubnetRule adds a subnet rule to the proxy handler.
// If portRanges is nil or empty, all ports are allowed for this subnet // HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") // rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { func (net *Net) AddProxySubnetRule(rule SubnetRule) {
tun := (*netTun)(net) tun := (*netTun)(net)
if tun.proxyHandler != nil { if tun.proxyHandler != nil {
tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) tun.proxyHandler.AddSubnetRule(rule)
} }
} }

View File

@@ -53,6 +53,7 @@ type Client struct {
processingMessage bool // Flag to track if a message is currently being processed processingMessage bool // Flag to track if a message is currently being processed
processingMux sync.RWMutex // Protects processingMessage processingMux sync.RWMutex // Protects processingMessage
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
} }
type ClientOption func(*Client) type ClientOption func(*Client)
@@ -102,6 +103,16 @@ func (c *Client) OnTokenUpdate(callback func(token string)) {
c.onTokenUpdate = callback c.onTokenUpdate = callback
} }
// WasJustProvisioned reports whether the client exchanged a provisioning key
// for permanent credentials during the most recent connection attempt. It
// consumes the flag subsequent calls return false until provisioning occurs
// again (which, in practice, never happens once credentials are persisted).
func (c *Client) WasJustProvisioned() bool {
v := c.justProvisioned
c.justProvisioned = false
return v
}
func (c *Client) metricsContext() context.Context { func (c *Client) metricsContext() context.Context {
c.metricsCtxMu.RLock() c.metricsCtxMu.RLock()
defer c.metricsCtxMu.RUnlock() defer c.metricsCtxMu.RUnlock()

View File

@@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@@ -70,6 +71,11 @@ func (c *Client) loadConfig() error {
} }
return err return err
} }
if len(bytes.TrimSpace(data)) == 0 {
logger.Info("Config file at %s is empty, will initialize it with provided values", configPath)
c.configNeedsSave = true
return nil
}
var config Config var config Config
if err := json.Unmarshal(data, &config); err != nil { if err := json.Unmarshal(data, &config); err != nil {
@@ -99,6 +105,10 @@ func (c *Client) loadConfig() error {
if c.config.ProvisioningKey == "" { if c.config.ProvisioningKey == "" {
c.config.ProvisioningKey = config.ProvisioningKey c.config.ProvisioningKey = config.ProvisioningKey
} }
// Always load the name from the file if not already set
if c.config.Name == "" {
c.config.Name = config.Name
}
// Check if CLI args provided values that override file values // Check if CLI args provided values that override file values
if (!fileHadID && originalConfig.ID != "") || if (!fileHadID && originalConfig.ID != "") ||
@@ -135,6 +145,21 @@ func (c *Client) saveConfig() error {
return err return err
} }
// interpolateString replaces {{env.VAR}} tokens in s with the corresponding
// environment variable values. Tokens that do not match a supported scheme are
// left unchanged, mirroring the blueprint interpolation logic.
func interpolateString(s string) string {
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
return re.ReplaceAllStringFunc(s, func(match string) string {
inner := strings.TrimSpace(match[2 : len(match)-2])
if strings.HasPrefix(inner, "env.") {
varName := strings.TrimPrefix(inner, "env.")
return os.Getenv(varName)
}
return match
})
}
// provisionIfNeeded checks whether a provisioning key is present and, if so, // provisionIfNeeded checks whether a provisioning key is present and, if so,
// exchanges it for a newt ID and secret by calling the registration endpoint. // exchanges it for a newt ID and secret by calling the registration endpoint.
// On success the config is updated in-place and flagged for saving so that // On success the config is updated in-place and flagged for saving so that
@@ -158,9 +183,15 @@ func (c *Client) provisionIfNeeded() error {
} }
baseEndpoint := strings.TrimRight(baseURL.String(), "/") baseEndpoint := strings.TrimRight(baseURL.String(), "/")
// Interpolate any {{env.VAR}} tokens in the name before sending.
name := interpolateString(c.config.Name)
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"provisioningKey": c.config.ProvisioningKey, "provisioningKey": c.config.ProvisioningKey,
} }
if name != "" {
reqBody["name"] = name
}
jsonData, err := json.Marshal(reqBody) jsonData, err := json.Marshal(reqBody)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal provisioning request: %w", err) return fmt.Errorf("failed to marshal provisioning request: %w", err)
@@ -236,7 +267,9 @@ func (c *Client) provisionIfNeeded() error {
c.config.ID = provResp.Data.NewtID c.config.ID = provResp.Data.NewtID
c.config.Secret = provResp.Data.Secret c.config.Secret = provResp.Data.Secret
c.config.ProvisioningKey = "" c.config.ProvisioningKey = ""
c.config.Name = ""
c.configNeedsSave = true c.configNeedsSave = true
c.justProvisioned = true
// Save immediately so that if the subsequent connection attempt fails the // Save immediately so that if the subsequent connection attempt fails the
// provisioning key is already gone from disk and the next retry uses the // provisioning key is already gone from disk and the next retry uses the

35
websocket/config_test.go Normal file
View File

@@ -0,0 +1,35 @@
package websocket
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
t.Setenv("CONFIG_FILE", "")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, []byte(""), 0o644); err != nil {
t.Fatalf("failed to create empty config file: %v", err)
}
client := &Client{
config: &Config{
Endpoint: "https://example.com",
ProvisioningKey: "spk-test",
},
clientType: "newt",
configFilePath: configPath,
}
if err := client.loadConfig(); err != nil {
t.Fatalf("loadConfig returned error for empty file: %v", err)
}
if !client.configNeedsSave {
t.Fatal("expected empty config file to mark configNeedsSave")
}
}

View File

@@ -6,6 +6,7 @@ type Config struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
TlsClientCert string `json:"tlsClientCert"` TlsClientCert string `json:"tlsClientCert"`
ProvisioningKey string `json:"provisioningKey,omitempty"` ProvisioningKey string `json:"provisioningKey,omitempty"`
Name string `json:"name,omitempty"`
} }
type TokenResponse struct { type TokenResponse struct {