Skip to content

Commit a0bb51f

Browse files
committed
Add support for updating TLS certificates in auth proxy
1 parent 5ba7d08 commit a0bb51f

File tree

2 files changed

+259
-23
lines changed

2 files changed

+259
-23
lines changed

auth/auth.go

Lines changed: 235 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package auth
33
import (
44
"context"
55
"crypto/rsa"
6+
"crypto/tls"
67
"crypto/x509"
78
"encoding/base64"
89
"encoding/json"
@@ -13,8 +14,8 @@ import (
1314
"net"
1415
"net/http"
1516
"net/http/httputil"
16-
"os"
1717
"net/url"
18+
"os"
1819
"strings"
1920
"sync"
2021
"time"
@@ -47,11 +48,21 @@ type ResourceAuthConfig struct {
4748
SSL bool `json:"ssl"` // Use SSL for backend
4849
}
4950

51+
// TLSCertificateConfig holds a TLS certificate pushed from Pangolin
52+
type TLSCertificateConfig struct {
53+
Domain string `json:"domain"` // Domain this cert covers (may be wildcard like *.example.com)
54+
CertPEM string `json:"certPem"` // PEM-encoded certificate chain
55+
KeyPEM string `json:"keyPem"` // PEM-encoded private key
56+
ExpiresAt int64 `json:"expiresAt"` // Unix timestamp when cert expires
57+
Wildcard bool `json:"wildcard"` // Whether this is a wildcard cert
58+
}
59+
5060
// AuthProxyConfig is the full config message from Pangolin
5161
type AuthProxyConfig struct {
52-
Action string `json:"action"` // "update", "remove", "start", "stop"
53-
Auth AuthConfig `json:"auth"`
54-
Resources []ResourceAuthConfig `json:"resources"`
62+
Action string `json:"action"` // "update", "remove", "start", "stop"
63+
Auth AuthConfig `json:"auth"`
64+
Resources []ResourceAuthConfig `json:"resources"`
65+
TLSCertificates []TLSCertificateConfig `json:"tlsCertificates,omitempty"`
5566
}
5667

5768
// AuthProxy handles authentication for direct-routed resources
@@ -66,6 +77,13 @@ type AuthProxy struct {
6677
ctx context.Context
6778
cancel context.CancelFunc
6879
listenAddr string
80+
httpsListenAddr string
81+
httpsServer *http.Server
82+
certStore map[string]*tls.Certificate // domain -> parsed TLS cert (lowercase)
83+
certWildcards map[string]*tls.Certificate // base domain -> wildcard cert (e.g. "example.com" -> *.example.com cert)
84+
hasCerts bool // whether any TLS certs have been loaded
85+
httpBindFailed bool // true if HTTP port was already in use (e.g. Traefik colocated)
86+
httpsBindFailed bool // true if HTTPS port was already in use
6987
}
7088

7189
// NewAuthProxy creates a new auth proxy
@@ -75,16 +93,23 @@ func NewAuthProxy() *AuthProxy {
7593
if listenAddr == "" {
7694
listenAddr = ":80"
7795
}
96+
httpsListenAddr := os.Getenv("NEWT_AUTH_PROXY_HTTPS_BIND")
97+
if httpsListenAddr == "" {
98+
httpsListenAddr = ":443"
99+
}
78100

79101
return &AuthProxy{
80-
resources: make(map[string]*ResourceAuthConfig),
81-
servers: make(map[string]*http.Server),
102+
resources: make(map[string]*ResourceAuthConfig),
103+
servers: make(map[string]*http.Server),
104+
certStore: make(map[string]*tls.Certificate),
105+
certWildcards: make(map[string]*tls.Certificate),
82106
httpClient: &http.Client{
83107
Timeout: 10 * time.Second,
84108
},
85-
ctx: ctx,
86-
cancel: cancel,
87-
listenAddr: listenAddr,
109+
ctx: ctx,
110+
cancel: cancel,
111+
listenAddr: listenAddr,
112+
httpsListenAddr: httpsListenAddr,
88113
}
89114
}
90115

@@ -152,31 +177,147 @@ func (p *AuthProxy) Start() error {
152177
return nil
153178
}
154179

180+
p.ctx, p.cancel = context.WithCancel(context.Background())
181+
182+
// Try to bind the HTTP port. If another process owns it (e.g. Traefik
183+
// colocated on the same machine), log a clear message and skip the HTTP
184+
// listener but still mark as running so certs/resources are stored.
185+
httpUp := false
155186
listener, err := net.Listen("tcp", p.listenAddr)
156187
if err != nil {
157-
return fmt.Errorf("auth proxy failed to bind on %s: %w", p.listenAddr, err)
188+
p.httpBindFailed = true
189+
logger.Warn("Auth Proxy: HTTP port %s is already in use by another process "+
190+
"(likely Traefik/Gerbil on this machine). HTTP listener skipped. "+
191+
"Set NEWT_AUTH_PROXY_BIND to use a different port.", p.listenAddr)
192+
} else {
193+
listener.Close()
194+
p.httpBindFailed = false
195+
196+
// HTTP server: serves requests directly when no TLS certs are available,
197+
// otherwise redirects to HTTPS
198+
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
199+
p.mu.RLock()
200+
hasCerts := p.hasCerts
201+
p.mu.RUnlock()
202+
203+
if hasCerts {
204+
// Redirect HTTP → HTTPS
205+
host := r.Host
206+
if h, _, err := net.SplitHostPort(host); err == nil {
207+
host = h
208+
}
209+
target := "https://" + host + r.RequestURI
210+
http.Redirect(w, r, target, http.StatusMovedPermanently)
211+
return
212+
}
213+
// No TLS certs loaded: serve directly on HTTP
214+
p.ServeHTTP(w, r)
215+
})
216+
217+
server := &http.Server{
218+
Addr: p.listenAddr,
219+
Handler: httpHandler,
220+
}
221+
p.servers["__default__"] = server
222+
223+
go func() {
224+
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
225+
logger.Error("Auth Proxy: HTTP server error on %s: %v", p.listenAddr, err)
226+
}
227+
}()
228+
httpUp = true
158229
}
159-
if err := listener.Close(); err != nil {
160-
return fmt.Errorf("auth proxy preflight close failed: %w", err)
230+
231+
// Start HTTPS server if we have certificates
232+
if p.hasCerts {
233+
p.startHTTPSServerLocked()
161234
}
162235

163-
p.ctx, p.cancel = context.WithCancel(context.Background())
236+
p.running = true
237+
238+
if httpUp {
239+
logger.Info("Auth Proxy: Started on %s", p.listenAddr)
240+
} else {
241+
logger.Info("Auth Proxy: Started (HTTP skipped — port in use; HTTPS will be attempted when certs arrive)")
242+
}
243+
return nil
244+
}
245+
246+
// startHTTPSServerLocked starts the HTTPS server. Must be called with p.mu held.
247+
func (p *AuthProxy) startHTTPSServerLocked() {
248+
if p.httpsServer != nil {
249+
return // already running
250+
}
251+
if p.httpsBindFailed {
252+
return // previously failed — don't retry until restart
253+
}
254+
255+
// Preflight check — if the HTTPS port is in use, record and skip
256+
ln, err := net.Listen("tcp", p.httpsListenAddr)
257+
if err != nil {
258+
p.httpsBindFailed = true
259+
logger.Warn("Auth Proxy: HTTPS port %s is already in use by another process "+
260+
"(likely Traefik/Gerbil on this machine). HTTPS listener skipped. "+
261+
"Set NEWT_AUTH_PROXY_HTTPS_BIND to use a different port.", p.httpsListenAddr)
262+
return
263+
}
264+
ln.Close()
265+
266+
tlsConfig := &tls.Config{
267+
GetCertificate: p.getCertificate,
268+
MinVersion: tls.VersionTLS12,
269+
}
164270

165-
server := &http.Server{
166-
Addr: p.listenAddr,
167-
Handler: p,
271+
p.httpsServer = &http.Server{
272+
Addr: p.httpsListenAddr,
273+
Handler: p, // use the same ServeHTTP handler
274+
TLSConfig: tlsConfig,
168275
}
169-
p.servers["__default__"] = server
170276

171277
go func() {
172-
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
173-
logger.Error("Auth Proxy: HTTP server error on %s: %v", p.listenAddr, err)
278+
// ListenAndServeTLS with empty cert/key files because GetCertificate handles it
279+
if err := p.httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
280+
logger.Error("Auth Proxy: HTTPS server error on %s: %v", p.httpsListenAddr, err)
174281
}
175282
}()
176283

177-
p.running = true
178-
logger.Info("Auth Proxy: Started on %s", p.listenAddr)
179-
return nil
284+
logger.Info("Auth Proxy: HTTPS server started on %s", p.httpsListenAddr)
285+
}
286+
287+
// stopHTTPSServerLocked stops the HTTPS server. Must be called with p.mu held.
288+
func (p *AuthProxy) stopHTTPSServerLocked() {
289+
if p.httpsServer == nil {
290+
return
291+
}
292+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
293+
defer cancel()
294+
p.httpsServer.Shutdown(ctx)
295+
p.httpsServer = nil
296+
logger.Info("Auth Proxy: HTTPS server stopped")
297+
}
298+
299+
// getCertificate is the tls.Config.GetCertificate callback for SNI-based cert selection
300+
func (p *AuthProxy) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
301+
p.mu.RLock()
302+
defer p.mu.RUnlock()
303+
304+
serverName := strings.ToLower(hello.ServerName)
305+
306+
// Try exact domain match first
307+
if cert, ok := p.certStore[serverName]; ok {
308+
return cert, nil
309+
}
310+
311+
// Try wildcard match: for "sub.example.com", check if we have a wildcard cert for "example.com"
312+
parts := strings.SplitN(serverName, ".", 2)
313+
if len(parts) == 2 {
314+
baseDomain := parts[1]
315+
if cert, ok := p.certWildcards[baseDomain]; ok {
316+
return cert, nil
317+
}
318+
}
319+
320+
return nil, fmt.Errorf("no certificate found for %s", serverName)
180321
}
181322

182323
// Stop stops the auth proxy
@@ -190,7 +331,10 @@ func (p *AuthProxy) Stop() error {
190331

191332
p.cancel()
192333

193-
// Shutdown all servers
334+
// Stop HTTPS server
335+
p.stopHTTPSServerLocked()
336+
337+
// Shutdown all HTTP servers
194338
for domain, server := range p.servers {
195339
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
196340
server.Shutdown(ctx)
@@ -457,6 +601,63 @@ func (p *AuthProxy) proxyToBackend(w http.ResponseWriter, r *http.Request, resou
457601
proxy.ServeHTTP(w, r)
458602
}
459603

604+
// UpdateCertificates updates the TLS certificate store with certificates pushed from Pangolin.
605+
// If certs are loaded for the first time and the proxy is already running, it starts the HTTPS server.
606+
func (p *AuthProxy) UpdateCertificates(certs []TLSCertificateConfig) error {
607+
p.mu.Lock()
608+
defer p.mu.Unlock()
609+
610+
newStore := make(map[string]*tls.Certificate)
611+
newWildcards := make(map[string]*tls.Certificate)
612+
loaded := 0
613+
614+
for _, certCfg := range certs {
615+
tlsCert, err := tls.X509KeyPair([]byte(certCfg.CertPEM), []byte(certCfg.KeyPEM))
616+
if err != nil {
617+
logger.Error("Auth Proxy: Failed to parse TLS cert for %s: %v", certCfg.Domain, err)
618+
continue
619+
}
620+
621+
domain := strings.ToLower(certCfg.Domain)
622+
623+
if certCfg.Wildcard {
624+
// Wildcard cert: domain is stored as the base domain (e.g. "example.com")
625+
// and covers *.example.com
626+
// Strip leading "*." if present
627+
baseDomain := domain
628+
if strings.HasPrefix(baseDomain, "*.") {
629+
baseDomain = baseDomain[2:]
630+
}
631+
newWildcards[baseDomain] = &tlsCert
632+
// Also store as exact match for the base domain itself
633+
newStore[baseDomain] = &tlsCert
634+
logger.Info("Auth Proxy: Loaded wildcard TLS cert for *.%s", baseDomain)
635+
} else {
636+
newStore[domain] = &tlsCert
637+
logger.Info("Auth Proxy: Loaded TLS cert for %s", domain)
638+
}
639+
loaded++
640+
}
641+
642+
p.certStore = newStore
643+
p.certWildcards = newWildcards
644+
hadCerts := p.hasCerts
645+
p.hasCerts = loaded > 0
646+
647+
// If we just got certs for the first time and the proxy is already running, start HTTPS
648+
if p.hasCerts && !hadCerts && p.running {
649+
p.startHTTPSServerLocked()
650+
}
651+
652+
// If we lost all certs, stop HTTPS
653+
if !p.hasCerts && hadCerts {
654+
p.stopHTTPSServerLocked()
655+
}
656+
657+
logger.Info("Auth Proxy: Certificate store updated with %d cert(s)", loaded)
658+
return nil
659+
}
660+
460661
// GetResource returns the auth config for a domain
461662
func (p *AuthProxy) GetResource(domain string) *ResourceAuthConfig {
462663
p.mu.RLock()
@@ -487,6 +688,17 @@ func (p *AuthProxy) IsRunning() bool {
487688
return p.running
488689
}
489690

691+
// BindStatus returns whether each listener is active, skipped (port in use), or not started.
692+
func (p *AuthProxy) BindStatus() (httpOk, httpsOk, httpSkipped, httpsSkipped bool) {
693+
p.mu.RLock()
694+
defer p.mu.RUnlock()
695+
httpOk = p.running && !p.httpBindFailed && p.servers["__default__"] != nil
696+
httpsOk = p.running && !p.httpsBindFailed && p.httpsServer != nil
697+
httpSkipped = p.httpBindFailed
698+
httpsSkipped = p.httpsBindFailed
699+
return
700+
}
701+
490702
// parseRSAPublicKey parses a PEM-encoded RSA public key
491703
func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
492704
// Try PEM decode first

main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,6 +1579,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
15791579
}
15801580
}
15811581

1582+
// Update TLS certificates if provided
1583+
if len(configMsg.TLSCertificates) > 0 {
1584+
if err := authProxy.UpdateCertificates(configMsg.TLSCertificates); err != nil {
1585+
logger.Error("Failed to update TLS certificates: %v", err)
1586+
} else {
1587+
logger.Info("Updated auth proxy with %d TLS certificate(s)", len(configMsg.TLSCertificates))
1588+
}
1589+
}
1590+
15821591
// Update global auth config
15831592
if err := authProxy.UpdateConfig(configMsg.Auth); err != nil {
15841593
logger.Error("Failed to update auth config: %v", err)
@@ -1588,6 +1597,21 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
15881597
authProxy.ReplaceResources(configMsg.Resources)
15891598
logger.Info("Updated auth proxy with %d resources", len(configMsg.Resources))
15901599

1600+
// Report auth proxy bind status back to Pangolin
1601+
httpOk, httpsOk, httpSkipped, httpsSkipped := authProxy.BindStatus()
1602+
statusData := map[string]interface{}{
1603+
"httpListening": httpOk,
1604+
"httpsListening": httpsOk,
1605+
"httpSkipped": httpSkipped,
1606+
"httpsSkipped": httpsSkipped,
1607+
"certCount": len(configMsg.TLSCertificates),
1608+
"resourceCount": len(configMsg.Resources),
1609+
}
1610+
if httpSkipped || httpsSkipped {
1611+
statusData["warning"] = "One or more auth proxy ports are already in use by another process (e.g. Traefik). Set NEWT_AUTH_PROXY_BIND / NEWT_AUTH_PROXY_HTTPS_BIND to use alternate ports."
1612+
}
1613+
_ = client.SendMessage("newt/auth/proxy/status", statusData)
1614+
15911615
case "remove":
15921616
if authProxy == nil {
15931617
return

0 commit comments

Comments
 (0)