From fcb849698f61f4a7e77eb79731a71c5614f16c28 Mon Sep 17 00:00:00 2001 From: pascal Date: Thu, 15 Jan 2026 17:54:16 +0100 Subject: [PATCH] add cert manager with self signed cert support --- proxy/config.example.json | 2 +- .../reverseproxy/certmanager/interface.go | 28 +++ .../reverseproxy/certmanager/letsencrypt.go | 113 ++++++++++++ .../certmanager/selfsigned_manager.go | 166 ++++++++++++++++++ proxy/internal/reverseproxy/config.go | 12 +- proxy/internal/reverseproxy/proxy.go | 124 +++++++++++-- proxy/internal/reverseproxy/routes.go | 18 +- proxy/internal/reverseproxy/server.go | 140 --------------- 8 files changed, 444 insertions(+), 159 deletions(-) create mode 100644 proxy/internal/reverseproxy/certmanager/interface.go create mode 100644 proxy/internal/reverseproxy/certmanager/letsencrypt.go create mode 100644 proxy/internal/reverseproxy/certmanager/selfsigned_manager.go delete mode 100644 proxy/internal/reverseproxy/server.go diff --git a/proxy/config.example.json b/proxy/config.example.json index a6d8e2498..11b92aadb 100644 --- a/proxy/config.example.json +++ b/proxy/config.example.json @@ -11,7 +11,7 @@ "listen_address": ":443", "management_url": "https://api.netbird.io", "http_listen_address": ":80", - "enable_https": false, + "cert_mode": "letsencrypt", "tls_email": "your-email@example.com", "cert_cache_dir": "./certs", "oidc_config": { diff --git a/proxy/internal/reverseproxy/certmanager/interface.go b/proxy/internal/reverseproxy/certmanager/interface.go new file mode 100644 index 000000000..bdf12f918 --- /dev/null +++ b/proxy/internal/reverseproxy/certmanager/interface.go @@ -0,0 +1,28 @@ +package certmanager + +import ( + "context" + "crypto/tls" + "net/http" +) + +// Manager defines the interface for certificate management +type Manager interface { + // IsEnabled returns whether certificate management is enabled + IsEnabled() bool + + // AddDomain adds a domain to the allowed hosts list + AddDomain(domain string) + + // RemoveDomain removes a domain from the allowed hosts list + RemoveDomain(domain string) + + // IssueCertificate eagerly issues a certificate for a domain + IssueCertificate(ctx context.Context, domain string) error + + // TLSConfig returns the TLS configuration for the HTTPS server + TLSConfig() *tls.Config + + // HTTPHandler returns the HTTP handler for ACME challenges (or fallback) + HTTPHandler(fallback http.Handler) http.Handler +} diff --git a/proxy/internal/reverseproxy/certmanager/letsencrypt.go b/proxy/internal/reverseproxy/certmanager/letsencrypt.go new file mode 100644 index 000000000..ed75379cf --- /dev/null +++ b/proxy/internal/reverseproxy/certmanager/letsencrypt.go @@ -0,0 +1,113 @@ +package certmanager + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/acme/autocert" +) + +// LetsEncryptManager handles TLS certificate issuance via Let's Encrypt +type LetsEncryptManager struct { + autocertManager *autocert.Manager + allowedHosts map[string]bool + mu sync.RWMutex +} + +// LetsEncryptConfig holds Let's Encrypt certificate manager configuration +type LetsEncryptConfig struct { + // Email for Let's Encrypt registration (required) + Email string + + // CertCacheDir is the directory to cache certificates + CertCacheDir string +} + +// NewLetsEncrypt creates a new Let's Encrypt certificate manager +func NewLetsEncrypt(config LetsEncryptConfig) *LetsEncryptManager { + m := &LetsEncryptManager{ + allowedHosts: make(map[string]bool), + } + + m.autocertManager = &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: m.hostPolicy, + Cache: autocert.DirCache(config.CertCacheDir), + Email: config.Email, + RenewBefore: 0, // Use default + } + + log.Info("Let's Encrypt certificate manager initialized") + return m +} + +// IsEnabled returns whether certificate management is enabled +func (m *LetsEncryptManager) IsEnabled() bool { + return true +} + +// AddDomain adds a domain to the allowed hosts list +func (m *LetsEncryptManager) AddDomain(domain string) { + m.mu.Lock() + defer m.mu.Unlock() + m.allowedHosts[domain] = true + log.Infof("Added domain to Let's Encrypt manager: %s", domain) +} + +// RemoveDomain removes a domain from the allowed hosts list +func (m *LetsEncryptManager) RemoveDomain(domain string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.allowedHosts, domain) + log.Infof("Removed domain from Let's Encrypt manager: %s", domain) +} + +// IssueCertificate eagerly issues a Let's Encrypt certificate for a domain +func (m *LetsEncryptManager) IssueCertificate(ctx context.Context, domain string) error { + log.Infof("Issuing Let's Encrypt certificate for domain: %s", domain) + + // Use GetCertificate to trigger certificate issuance + // This will go through the ACME challenge flow + hello := &tls.ClientHelloInfo{ + ServerName: domain, + } + + cert, err := m.autocertManager.GetCertificate(hello) + if err != nil { + return fmt.Errorf("failed to issue certificate for domain %s: %w", domain, err) + } + + log.Infof("Successfully issued Let's Encrypt certificate for domain: %s (expires: %s)", + domain, cert.Leaf.NotAfter.Format(time.RFC3339)) + + return nil +} + +// TLSConfig returns the TLS configuration for the HTTPS server +func (m *LetsEncryptManager) TLSConfig() *tls.Config { + return m.autocertManager.TLSConfig() +} + +// HTTPHandler returns the HTTP handler for ACME challenges +func (m *LetsEncryptManager) HTTPHandler(fallback http.Handler) http.Handler { + return m.autocertManager.HTTPHandler(fallback) +} + +// hostPolicy validates that a requested host is in the allowed hosts list +func (m *LetsEncryptManager) hostPolicy(ctx context.Context, host string) error { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.allowedHosts[host] { + log.Debugf("ACME challenge accepted for domain: %s", host) + return nil + } + + log.Warnf("ACME challenge rejected for unconfigured domain: %s", host) + return fmt.Errorf("host %s not configured", host) +} diff --git a/proxy/internal/reverseproxy/certmanager/selfsigned_manager.go b/proxy/internal/reverseproxy/certmanager/selfsigned_manager.go new file mode 100644 index 000000000..6b1894743 --- /dev/null +++ b/proxy/internal/reverseproxy/certmanager/selfsigned_manager.go @@ -0,0 +1,166 @@ +package certmanager + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net/http" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// SelfSignedManager handles self-signed certificate generation for local testing +type SelfSignedManager struct { + certificates map[string]*tls.Certificate // domain -> certificate cache + mu sync.RWMutex +} + +// NewSelfSigned creates a new self-signed certificate manager +func NewSelfSigned() *SelfSignedManager { + log.Info("Self-signed certificate manager initialized") + return &SelfSignedManager{ + certificates: make(map[string]*tls.Certificate), + } +} + +// IsEnabled returns whether certificate management is enabled +func (m *SelfSignedManager) IsEnabled() bool { + return true +} + +// AddDomain adds a domain to the manager (no-op for self-signed, but maintains interface) +func (m *SelfSignedManager) AddDomain(domain string) { + log.Infof("Added domain to self-signed manager: %s", domain) +} + +// RemoveDomain removes a domain from the manager +func (m *SelfSignedManager) RemoveDomain(domain string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.certificates, domain) + log.Infof("Removed domain from self-signed manager: %s", domain) +} + +// IssueCertificate generates and caches a self-signed certificate for a domain +func (m *SelfSignedManager) IssueCertificate(ctx context.Context, domain string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if we already have a certificate for this domain + if _, exists := m.certificates[domain]; exists { + log.Debugf("Self-signed certificate already exists for domain: %s", domain) + return nil + } + + // Generate self-signed certificate + cert, err := m.generateCertificate(domain) + if err != nil { + return err + } + + // Cache the certificate + m.certificates[domain] = cert + + return nil +} + +// TLSConfig returns the TLS configuration for the HTTPS server +func (m *SelfSignedManager) TLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: m.getCertificate, + } +} + +// HTTPHandler returns the fallback handler (no ACME challenges for self-signed) +func (m *SelfSignedManager) HTTPHandler(fallback http.Handler) http.Handler { + return fallback +} + +// getCertificate returns a self-signed certificate for the requested domain +func (m *SelfSignedManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + m.mu.RLock() + cert, exists := m.certificates[hello.ServerName] + m.mu.RUnlock() + + if exists { + return cert, nil + } + + // Generate certificate on-demand if not cached + log.Infof("Generating self-signed certificate on-demand for: %s", hello.ServerName) + + newCert, err := m.generateCertificate(hello.ServerName) + if err != nil { + return nil, err + } + + // Cache it + m.mu.Lock() + m.certificates[hello.ServerName] = newCert + m.mu.Unlock() + + return newCert, nil +} + +// generateCertificate generates a self-signed certificate for a domain +func (m *SelfSignedManager) generateCertificate(domain string) (*tls.Certificate, error) { + // Generate private key + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %w", err) + } + + // Create certificate template + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"NetBird Local Development"}, + CommonName: domain, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{domain}, + } + + // Create self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + // Parse certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + tlsCert := &tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: priv, + Leaf: cert, + } + + log.Infof("Generated self-signed certificate for domain: %s (expires: %s)", + domain, cert.NotAfter.Format(time.RFC3339)) + + return tlsCert, nil +} diff --git a/proxy/internal/reverseproxy/config.go b/proxy/internal/reverseproxy/config.go index 9e9b9bc1c..f439963db 100644 --- a/proxy/internal/reverseproxy/config.go +++ b/proxy/internal/reverseproxy/config.go @@ -18,16 +18,18 @@ type Config struct { ManagementURL string `env:"NB_REVERSE_PROXY_MANAGEMENT_URL" json:"management_url"` // HTTPListenAddress is the address for HTTP (default ":80") - // Used for ACME challenges when HTTPS is enabled, or as main listener when HTTPS is disabled + // Used for ACME challenges (Let's Encrypt HTTP-01 challenge) HTTPListenAddress string `env:"NB_REVERSE_PROXY_HTTP_LISTEN_ADDRESS" envDefault:":80" json:"http_listen_address"` - // EnableHTTPS enables automatic HTTPS with Let's Encrypt - EnableHTTPS bool `env:"NB_REVERSE_PROXY_ENABLE_HTTPS" envDefault:"false" json:"enable_https"` + // CertMode specifies certificate mode: "letsencrypt" or "selfsigned" (default: "letsencrypt") + // "letsencrypt" - Uses Let's Encrypt for production certificates (requires public domain) + // "selfsigned" - Generates self-signed certificates for local testing + CertMode string `env:"NB_REVERSE_PROXY_CERT_MODE" envDefault:"letsencrypt" json:"cert_mode"` - // TLSEmail is the email for Let's Encrypt registration + // TLSEmail is the email for Let's Encrypt registration (required for letsencrypt mode) TLSEmail string `env:"NB_REVERSE_PROXY_TLS_EMAIL" json:"tls_email"` - // CertCacheDir is the directory to cache certificates (default "./certs") + // CertCacheDir is the directory to cache certificates (for letsencrypt mode, default "./certs") CertCacheDir string `env:"NB_REVERSE_PROXY_CERT_CACHE_DIR" envDefault:"./certs" json:"cert_cache_dir"` // OIDCConfig is the global OIDC/OAuth configuration for authentication diff --git a/proxy/internal/reverseproxy/proxy.go b/proxy/internal/reverseproxy/proxy.go index 66f3e7a93..d5d9ed20a 100644 --- a/proxy/internal/reverseproxy/proxy.go +++ b/proxy/internal/reverseproxy/proxy.go @@ -1,13 +1,15 @@ package reverseproxy import ( + "context" "fmt" "net/http" "sync" - "golang.org/x/crypto/acme/autocert" + log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/proxy/internal/auth/oidc" + "github.com/netbirdio/netbird/proxy/internal/reverseproxy/certmanager" ) // Proxy wraps a reverse proxy with dynamic routing @@ -17,7 +19,7 @@ type Proxy struct { routes map[string]*RouteConfig // key is host/domain (for fast O(1) lookup) server *http.Server httpServer *http.Server - autocertManager *autocert.Manager + certManager certmanager.Manager isRunning bool requestCallback RequestDataCallback oidcHandler *oidc.Handler @@ -36,11 +38,14 @@ func New(config Config) (*Proxy, error) { config.CertCacheDir = "./certs" } - // Validate HTTPS config - if config.EnableHTTPS { - if config.TLSEmail == "" { - return nil, fmt.Errorf("TLSEmail is required when EnableHTTPS is true") - } + // Set default cert mode + if config.CertMode == "" { + config.CertMode = "letsencrypt" + } + + // Validate config based on cert mode + if config.CertMode == "letsencrypt" && config.TLSEmail == "" { + return nil, fmt.Errorf("TLSEmail is required for letsencrypt mode") } // Set default OIDC session cookie name if not provided @@ -48,10 +53,24 @@ func New(config Config) (*Proxy, error) { config.OIDCConfig.SessionCookieName = "auth_session" } + // Initialize certificate manager based on mode + var certMgr certmanager.Manager + if config.CertMode == "selfsigned" { + // HTTPS with self-signed certificates (for local testing) + certMgr = certmanager.NewSelfSigned() + } else { + // HTTPS with Let's Encrypt (for production) + certMgr = certmanager.NewLetsEncrypt(certmanager.LetsEncryptConfig{ + Email: config.TLSEmail, + CertCacheDir: config.CertCacheDir, + }) + } + p := &Proxy{ - config: config, - routes: make(map[string]*RouteConfig), - isRunning: false, + config: config, + routes: make(map[string]*RouteConfig), + certManager: certMgr, + isRunning: false, } // Initialize OIDC handler if OIDC is configured @@ -64,6 +83,91 @@ func New(config Config) (*Proxy, error) { return p, nil } +// Start starts the reverse proxy server (non-blocking) +func (p *Proxy) Start() error { + p.mu.Lock() + if p.isRunning { + p.mu.Unlock() + return fmt.Errorf("reverse proxy already running") + } + p.isRunning = true + p.mu.Unlock() + + // Build the main HTTP handler + handler := p.buildHandler() + + return p.startHTTPS(handler) +} + +// startHTTPS starts the proxy with HTTPS (non-blocking) +func (p *Proxy) startHTTPS(handler http.Handler) error { + // Start HTTP server for ACME challenges (Let's Encrypt HTTP-01) + p.httpServer = &http.Server{ + Addr: p.config.HTTPListenAddress, + Handler: p.certManager.HTTPHandler(nil), + } + + go func() { + log.Infof("Starting HTTP server for ACME challenges on %s", p.config.HTTPListenAddress) + if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Errorf("HTTP server error: %v", err) + } + }() + + // Start HTTPS server in background + p.server = &http.Server{ + Addr: p.config.ListenAddress, + Handler: handler, + TLSConfig: p.certManager.TLSConfig(), + } + + go func() { + log.Infof("Starting HTTPS reverse proxy server on %s", p.config.ListenAddress) + if err := p.server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + log.Errorf("HTTPS server failed: %v", err) + } + }() + + return nil +} + +// Stop gracefully stops the reverse proxy server +func (p *Proxy) Stop(ctx context.Context) error { + p.mu.Lock() + if !p.isRunning { + p.mu.Unlock() + return fmt.Errorf("reverse proxy not running") + } + p.isRunning = false + p.mu.Unlock() + + log.Info("Stopping reverse proxy server...") + + // Stop HTTP server (for ACME challenges) + if p.httpServer != nil { + if err := p.httpServer.Shutdown(ctx); err != nil { + log.Errorf("Error shutting down HTTP server: %v", err) + } + } + + // Stop main server + if p.server != nil { + if err := p.server.Shutdown(ctx); err != nil { + return fmt.Errorf("error shutting down server: %w", err) + } + } + + log.Info("Reverse proxy server stopped") + return nil +} + +// IsRunning returns whether the proxy is running +func (p *Proxy) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.isRunning +} + // SetRequestCallback sets the callback for request metrics func (p *Proxy) SetRequestCallback(callback RequestDataCallback) { p.mu.Lock() diff --git a/proxy/internal/reverseproxy/routes.go b/proxy/internal/reverseproxy/routes.go index a09d6ed7a..54295e088 100644 --- a/proxy/internal/reverseproxy/routes.go +++ b/proxy/internal/reverseproxy/routes.go @@ -56,15 +56,24 @@ func (p *Proxy) AddRoute(route *RouteConfig) error { // Add route with domain as key p.routes[route.Domain] = route + // Register domain with certificate manager + p.certManager.AddDomain(route.Domain) + log.WithFields(log.Fields{ "route_id": route.ID, "domain": route.Domain, "paths": len(route.PathMappings), }).Info("Added route") - // Note: With this architecture, we don't need to reload the server - // The handler dynamically looks up routes on each request - // Certificates will be obtained automatically when the domain is first accessed + // Eagerly issue certificate in background + go func(domain string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + if err := p.certManager.IssueCertificate(ctx, domain); err != nil { + log.Errorf("Failed to issue certificate: %v", err) + } + }(route.Domain) return nil } @@ -82,6 +91,9 @@ func (p *Proxy) RemoveRoute(domain string) error { // Remove route delete(p.routes, domain) + // Unregister domain from certificate manager + p.certManager.RemoveDomain(domain) + log.Infof("Removed route for domain: %s", domain) return nil } diff --git a/proxy/internal/reverseproxy/server.go b/proxy/internal/reverseproxy/server.go deleted file mode 100644 index f9389266f..000000000 --- a/proxy/internal/reverseproxy/server.go +++ /dev/null @@ -1,140 +0,0 @@ -package reverseproxy - -import ( - "context" - "fmt" - "net/http" - - log "github.com/sirupsen/logrus" - "golang.org/x/crypto/acme/autocert" -) - -// Start starts the reverse proxy server (non-blocking) -func (p *Proxy) Start() error { - p.mu.Lock() - if p.isRunning { - p.mu.Unlock() - return fmt.Errorf("reverse proxy already running") - } - p.isRunning = true - p.mu.Unlock() - - // Build the main HTTP handler - handler := p.buildHandler() - - if p.config.EnableHTTPS { - return p.startHTTPS(handler) - } - - return p.startHTTP(handler) -} - -// startHTTPS starts the proxy with HTTPS and Let's Encrypt (non-blocking) -func (p *Proxy) startHTTPS(handler http.Handler) error { - // Setup autocert manager with dynamic host policy - p.autocertManager = &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: p.dynamicHostPolicy, - Cache: autocert.DirCache(p.config.CertCacheDir), - Email: p.config.TLSEmail, - RenewBefore: 0, // Use default - } - - // Start HTTP server for ACME challenges - p.httpServer = &http.Server{ - Addr: p.config.HTTPListenAddress, - Handler: p.autocertManager.HTTPHandler(nil), - } - - go func() { - log.Infof("Starting HTTP server for ACME challenges on %s", p.config.HTTPListenAddress) - if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTP server error: %v", err) - } - }() - - // Start HTTPS server in background - p.server = &http.Server{ - Addr: p.config.ListenAddress, - Handler: handler, - TLSConfig: p.autocertManager.TLSConfig(), - } - - go func() { - log.Infof("Starting HTTPS reverse proxy server on %s", p.config.ListenAddress) - if err := p.server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTPS server failed: %v", err) - } - }() - - return nil -} - -// startHTTP starts the proxy with HTTP only (non-blocking) -func (p *Proxy) startHTTP(handler http.Handler) error { - p.server = &http.Server{ - Addr: p.config.HTTPListenAddress, - Handler: handler, - } - - go func() { - log.Infof("Starting HTTP reverse proxy server on %s (HTTPS disabled)", p.config.HTTPListenAddress) - if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTP server failed: %w", err) - } - }() - - return nil -} - -// dynamicHostPolicy validates that a requested host has a configured route -func (p *Proxy) dynamicHostPolicy(ctx context.Context, host string) error { - p.mu.RLock() - defer p.mu.RUnlock() - - // Check if we have a route for this host - if _, exists := p.routes[host]; exists { - log.Infof("ACME challenge accepted for configured host: %s", host) - return nil - } - - log.Warnf("ACME challenge rejected for unconfigured host: %s", host) - return fmt.Errorf("host %s not configured", host) -} - -// Stop gracefully stops the reverse proxy server -func (p *Proxy) Stop(ctx context.Context) error { - p.mu.Lock() - if !p.isRunning { - p.mu.Unlock() - return fmt.Errorf("reverse proxy not running") - } - p.isRunning = false - p.mu.Unlock() - - log.Info("Stopping reverse proxy server...") - - // Stop HTTP server (for ACME challenges) - if p.httpServer != nil { - if err := p.httpServer.Shutdown(ctx); err != nil { - log.Errorf("Error shutting down HTTP server: %v", err) - } - } - - // Stop main server - if p.server != nil { - if err := p.server.Shutdown(ctx); err != nil { - return fmt.Errorf("error shutting down server: %w", err) - } - } - - log.Info("Reverse proxy server stopped") - return nil -} - -// IsRunning returns whether the proxy is running -func (p *Proxy) IsRunning() bool { - p.mu.RLock() - defer p.mu.RUnlock() - return p.isRunning -}