@@ -3,6 +3,7 @@ package auth
33import (
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
5161type 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
461662func (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
491703func parseRSAPublicKey (pemStr string ) (* rsa.PublicKey , error ) {
492704 // Try PEM decode first
0 commit comments