Compare commits

...
Author SHA1 Message Date
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
4 changed files with 216 additions and 13 deletions
+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)
}
+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.
+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)
}
+1
View File
@@ -70,5 +70,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"`
}