[GH-ISSUE #5442] Combined support for static certificate and Let's Encrypt in proxy #10564

Open
opened 2026-08-05 01:26:23 -04:00 by saavagebueno · 1 comment
Owner

Originally created by @Wouter0100 on GitHub (Feb 25, 2026).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/5442

Is your feature request related to a problem? Please describe.
Currently, NetBird supports either a static certificate or Let's Encrypt. However, I think it might be very interesting to support both a static certificate for the proxy domain (so you're able to provide a wildcard cert) and Let's Encrypt ACME certs for Custom Domains.

Describe the solution you'd like
Being able to have a wildcard certificate for the proxy domain, and have automated certs for Custom Domains as well. This reduces the number of certificates we request by a lot, and improves the performance of the netbird expose-command since it does not need to request a cert.

Describe alternatives you've considered
I did quickly dive into the code to consider using a different ACME challenge, but the acme/autocert package unfortunately only supports HTTP-based ACME challenges, meaning requesting a wildcard certificate through it is implossible.

Then I considered alternatives, and the easiest way to get to this feature is to combine the static certificate feature (in which I'd request a wildcard cert via other means) and still have the Custom Domains feature use Let's Encrypt.

Additional context
n/a

Originally created by @Wouter0100 on GitHub (Feb 25, 2026). Original GitHub issue: https://github.com/netbirdio/netbird/issues/5442 **Is your feature request related to a problem? Please describe.** Currently, NetBird supports either a static certificate **or** Let's Encrypt. However, I think it might be very interesting to support both a static certificate for the proxy domain (so you're able to provide a wildcard cert) **and** Let's Encrypt ACME certs for Custom Domains. **Describe the solution you'd like** Being able to have a wildcard certificate for the proxy domain, and have automated certs for Custom Domains as well. This reduces the number of certificates we request by a lot, and improves the performance of the `netbird expose`-command since it does not need to request a cert. **Describe alternatives you've considered** I did quickly dive into the code to consider using a different ACME challenge, but the acme/autocert package unfortunately only supports HTTP-based ACME challenges, meaning requesting a wildcard certificate through it is implossible. Then I considered alternatives, and the easiest way to get to this feature is to combine the static certificate feature (in which I'd request a wildcard cert via other means) and still have the Custom Domains feature use Let's Encrypt. **Additional context** n/a
saavagebueno added the feature-request label 2026-08-05 01:26:23 -04:00
Author
Owner

@Wouter0100 commented on GitHub (Feb 25, 2026):

I've LMM-assisted implemented this feature. If this is an interested feature, I'd love to further refine and test this before submitting it as a MR.

func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
	tlsConfig := &tls.Config{}

	// Determine whether a static certificate is configured on disk
	haveStatic := s.CertificateDirectory != "" && s.CertificateFile != "" && s.CertificateKeyFile != ""

	// If ACME is disabled, keep existing behavior: static watcher only
	if !s.GenerateACMECertificates {
		s.Logger.Debug("ACME certificates disabled, using static certificates with file watching")
		certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile)
		keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile)

		certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger)
		if err != nil {
			return nil, fmt.Errorf("initialize certificate watcher: %w", err)
		}
		go certWatcher.Watch(ctx)
		tlsConfig.GetCertificate = certWatcher.GetCertificate
		return tlsConfig, nil
	}

	// ACME is enabled from here on. Initialize ACME manager as usual.
	if s.ACMEChallengeType == "" {
		s.ACMEChallengeType = "tls-alpn-01"
	}
	s.Logger.WithFields(log.Fields{
		"acme_server":    s.ACMEDirectory,
		"challenge_type": s.ACMEChallengeType,
	}).Debug("ACME certificates enabled, configuring certificate manager")
	s.acme = acme.NewManager(s.CertificateDirectory, s.ACMEDirectory, s, s.Logger, s.CertLockMethod)

	if s.ACMEChallengeType == "http-01" {
		s.http = &http.Server{
			Addr:     s.ACMEChallengeAddress,
			Handler:  s.acme.HTTPHandler(nil),
			ErrorLog: newHTTPServerLogger(s.Logger, logtagValueACME),
		}
		go func() {
			if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
				s.Logger.WithError(err).Error("ACME HTTP-01 challenge server failed")
			}
		}()
	}

	// Start from ACME's TLS config to preserve ALPN/challenge settings
	tlsConfig = s.acme.TLSConfig()

	// If we also have a static certificate configured, enable hybrid mode
	if haveStatic {
		certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile)
		keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile)
		certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger)
		if err != nil {
			return nil, fmt.Errorf("initialize certificate watcher: %w", err)
		}
		go certWatcher.Watch(ctx)

		acmeGetCert := tlsConfig.GetCertificate // ACME's handler

		// Wrap GetCertificate to prefer static cert when it covers SNI
		tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
			// Try static certificate first
			if staticCert, err := certWatcher.GetCertificate(hello); err == nil && staticCert != nil {
				if certCoversServerName(staticCert, hello.ServerName) {
					return staticCert, nil
				}
			}
			// Fallback to ACME manager
			if acmeGetCert != nil {
				return acmeGetCert(hello)
			}
			return nil, fmt.Errorf("no certificate available for %q", hello.ServerName)
		}
		s.Logger.Debug("Hybrid TLS enabled: using static certificate when it covers SNI; otherwise falling back to ACME")
	}

	// ServerName needs to be set to allow for ACME to work correctly
	// when using CNAME URLs to access the proxy.
	tlsConfig.ServerName = s.ProxyURL

	s.Logger.WithFields(log.Fields{
		"ServerName":     s.ProxyURL,
		"challenge_type": s.ACMEChallengeType,
	}).Debug("ACME certificate manager configured")
	return tlsConfig, nil
}

// certCoversServerName returns true if the provided certificate covers the
// given serverName via an exact SAN match, a valid single-label wildcard SAN,
// or (legacy) Common Name when SANs are absent.
func certCoversServerName(cert *tls.Certificate, serverName string) bool {
	if cert == nil || serverName == "" {
		return false
	}

	// Ensure we have a parsed leaf certificate
	var leaf *x509.Certificate
	if cert.Leaf != nil {
		leaf = cert.Leaf
	} else if len(cert.Certificate) > 0 {
		if parsed, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
			leaf = parsed
		}
	}
	if leaf == nil {
		return false
	}

	// Prefer SANs
	if len(leaf.DNSNames) > 0 {
		for _, name := range leaf.DNSNames {
			if dnsNameMatches(name, serverName) {
				return true
			}
		}
		return false
	}

	// Fallback to CN if no SANs present (legacy behavior)
	if leaf.Subject.CommonName != "" {
		return dnsNameMatches(leaf.Subject.CommonName, serverName)
	}
	return false
}

// dnsNameMatches checks exact or single-label wildcard matches according to RFC 6125.
func dnsNameMatches(pattern, host string) bool {
	// Exact match
	if strings.EqualFold(pattern, host) {
		return true
	}

	// Wildcard match: only a single wildcard label allowed at the left-most position
	if strings.HasPrefix(pattern, "*.") {
		base := pattern[2:]
		if base == "" {
			return false
		}
		// host must have exactly one additional label than base and end with "."+base
		if strings.HasSuffix(host, "."+base) {
			// e.g., host=a.example.com (labels=3), base=example.com (labels=2)
			if labelCount(host) == labelCount(base)+1 {
				return true
			}
		}
	}
	return false
}

func labelCount(name string) int {
	if name == "" {
		return 0
	}
	return strings.Count(name, ".") + 1
}
<!-- gh-comment-id:3957566561 --> @Wouter0100 commented on GitHub (Feb 25, 2026): I've LMM-assisted implemented this feature. If this is an interested feature, I'd love to further refine and test this before submitting it as a MR. ```golang func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) { tlsConfig := &tls.Config{} // Determine whether a static certificate is configured on disk haveStatic := s.CertificateDirectory != "" && s.CertificateFile != "" && s.CertificateKeyFile != "" // If ACME is disabled, keep existing behavior: static watcher only if !s.GenerateACMECertificates { s.Logger.Debug("ACME certificates disabled, using static certificates with file watching") certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile) keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile) certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger) if err != nil { return nil, fmt.Errorf("initialize certificate watcher: %w", err) } go certWatcher.Watch(ctx) tlsConfig.GetCertificate = certWatcher.GetCertificate return tlsConfig, nil } // ACME is enabled from here on. Initialize ACME manager as usual. if s.ACMEChallengeType == "" { s.ACMEChallengeType = "tls-alpn-01" } s.Logger.WithFields(log.Fields{ "acme_server": s.ACMEDirectory, "challenge_type": s.ACMEChallengeType, }).Debug("ACME certificates enabled, configuring certificate manager") s.acme = acme.NewManager(s.CertificateDirectory, s.ACMEDirectory, s, s.Logger, s.CertLockMethod) if s.ACMEChallengeType == "http-01" { s.http = &http.Server{ Addr: s.ACMEChallengeAddress, Handler: s.acme.HTTPHandler(nil), ErrorLog: newHTTPServerLogger(s.Logger, logtagValueACME), } go func() { if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { s.Logger.WithError(err).Error("ACME HTTP-01 challenge server failed") } }() } // Start from ACME's TLS config to preserve ALPN/challenge settings tlsConfig = s.acme.TLSConfig() // If we also have a static certificate configured, enable hybrid mode if haveStatic { certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile) keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile) certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger) if err != nil { return nil, fmt.Errorf("initialize certificate watcher: %w", err) } go certWatcher.Watch(ctx) acmeGetCert := tlsConfig.GetCertificate // ACME's handler // Wrap GetCertificate to prefer static cert when it covers SNI tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { // Try static certificate first if staticCert, err := certWatcher.GetCertificate(hello); err == nil && staticCert != nil { if certCoversServerName(staticCert, hello.ServerName) { return staticCert, nil } } // Fallback to ACME manager if acmeGetCert != nil { return acmeGetCert(hello) } return nil, fmt.Errorf("no certificate available for %q", hello.ServerName) } s.Logger.Debug("Hybrid TLS enabled: using static certificate when it covers SNI; otherwise falling back to ACME") } // ServerName needs to be set to allow for ACME to work correctly // when using CNAME URLs to access the proxy. tlsConfig.ServerName = s.ProxyURL s.Logger.WithFields(log.Fields{ "ServerName": s.ProxyURL, "challenge_type": s.ACMEChallengeType, }).Debug("ACME certificate manager configured") return tlsConfig, nil } // certCoversServerName returns true if the provided certificate covers the // given serverName via an exact SAN match, a valid single-label wildcard SAN, // or (legacy) Common Name when SANs are absent. func certCoversServerName(cert *tls.Certificate, serverName string) bool { if cert == nil || serverName == "" { return false } // Ensure we have a parsed leaf certificate var leaf *x509.Certificate if cert.Leaf != nil { leaf = cert.Leaf } else if len(cert.Certificate) > 0 { if parsed, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { leaf = parsed } } if leaf == nil { return false } // Prefer SANs if len(leaf.DNSNames) > 0 { for _, name := range leaf.DNSNames { if dnsNameMatches(name, serverName) { return true } } return false } // Fallback to CN if no SANs present (legacy behavior) if leaf.Subject.CommonName != "" { return dnsNameMatches(leaf.Subject.CommonName, serverName) } return false } // dnsNameMatches checks exact or single-label wildcard matches according to RFC 6125. func dnsNameMatches(pattern, host string) bool { // Exact match if strings.EqualFold(pattern, host) { return true } // Wildcard match: only a single wildcard label allowed at the left-most position if strings.HasPrefix(pattern, "*.") { base := pattern[2:] if base == "" { return false } // host must have exactly one additional label than base and end with "."+base if strings.HasSuffix(host, "."+base) { // e.g., host=a.example.com (labels=3), base=example.com (labels=2) if labelCount(host) == labelCount(base)+1 { return true } } } return false } func labelCount(name string) int { if name == "" { return 0 } return strings.Count(name, ".") + 1 } ```
Sign in to join this conversation.
No Label feature-request
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#10564