app: route ACME HTTP-01 listener through errgroup

The HTTP-01 challenge listener was launched in an orphan goroutine that
called log.Fatal on bind failure, which bypassed the signal-handler
shutdown path and made bind errors look identical to the main HTTP
listener.

Bind eagerly via net.ListenConfig in getTLSSettings, return the listener
+ http.Server in a tlsBundle, register the Serve loop with the existing
errgroup, and call Shutdown alongside the other servers. Bind failures
now surface as ListenerBindError(Listener:"ACME HTTP-01 challenge")
through the normal error chain.

Updates #3227
This commit is contained in:
Kristoffer Dalby
2026-04-30 08:40:42 +00:00
parent 37e1904924
commit 61021c739a
+63 -21
View File
@@ -678,11 +678,13 @@ func (h *Headscale) Serve() error {
// Set up REMOTE listeners
//
tlsConfig, err := h.getTLSSettings()
tlsBundle, err := h.getTLSSettings(ctx)
if err != nil {
return fmt.Errorf("configuring TLS settings: %w", err)
}
tlsConfig := tlsBundle.Config
//
//
// HTTP setup
@@ -724,6 +726,20 @@ func (h *Headscale) Serve() error {
log.Info().
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)
if tlsBundle.ACMEServer != nil {
log.Info().Msgf(
"listening and serving ACME HTTP-01 challenge on: %s",
tlsBundle.ACMEListener.Addr())
errorGroup.Go(func() error {
err := tlsBundle.ACMEServer.Serve(tlsBundle.ACMEListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("ACME HTTP-01 challenge listener: %w", err)
}
return nil
})
}
// Only start debug/metrics server if address is configured
var debugHTTPServer *http.Server
@@ -838,6 +854,17 @@ func (h *Headscale) Serve() error {
log.Error().Err(err).Msg("failed to shutdown http")
}
if tlsBundle.ACMEServer != nil {
info("shutting down ACME HTTP-01 challenge server")
err := tlsBundle.ACMEServer.Shutdown(shutdownCtx)
if err != nil {
log.Error().Err(err).Msg("failed to shutdown ACME HTTP-01 server")
}
tlsBundle.ACMEListener.Close()
}
info("closing batcher")
h.mapBatcher.Close()
@@ -893,7 +920,17 @@ func (h *Headscale) Serve() error {
return errorGroup.Wait()
}
func (h *Headscale) getTLSSettings() (*tls.Config, error) {
// tlsBundle carries the TLS settings produced by getTLSSettings. When
// HTTP-01 ACME is configured, ACMEServer and ACMEListener are populated
// so the caller can register the challenge listener with the errgroup
// and wire it into the shutdown path. Otherwise both are nil.
type tlsBundle struct {
Config *tls.Config
ACMEServer *http.Server
ACMEListener net.Listener
}
func (h *Headscale) getTLSSettings(ctx context.Context) (*tlsBundle, error) {
tlsEnabled := h.cfg.TLS.LetsEncrypt.Hostname != "" || h.cfg.TLS.CertPath != ""
if tlsEnabled && !strings.HasPrefix(h.cfg.ServerURL, "https://") {
log.Warn().Msg("listening with TLS but ServerURL does not start with https://")
@@ -922,7 +959,7 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
// Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737)
// The RFC requires that the validation is done on port 443; in other words, headscale
// must be reachable on port 443.
return certManager.TLSConfig(), nil
return &tlsBundle{Config: certManager.TLSConfig()}, nil
case types.HTTP01ChallengeType:
// Configuration via autocert with HTTP-01. This requires listening on
@@ -934,15 +971,21 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
ReadTimeout: types.HTTPTimeout,
}
go func() {
err := server.ListenAndServe()
log.Fatal().
Caller().
Err(err).
Msg("failed to set up a HTTP server")
}()
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", server.Addr)
if err != nil {
return nil, &types.ListenerBindError{
Listener: "ACME HTTP-01 challenge",
YAMLKey: "tls_letsencrypt_listen",
Addr: server.Addr,
Err: err,
}
}
return certManager.TLSConfig(), nil
return &tlsBundle{
Config: certManager.TLSConfig(),
ACMEServer: server,
ACMEListener: listener,
}, nil
default:
return nil, errUnsupportedLetsEncryptChallengeType
@@ -950,23 +993,22 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
}
if h.cfg.TLS.CertPath == "" {
return nil, nil //nolint:nilnil // intentional: no TLS config when neither LetsEncrypt nor a cert path is set
}
tlsConfig := &tls.Config{
NextProtos: []string{"http/1.1"},
Certificates: make([]tls.Certificate, 1),
MinVersion: tls.VersionTLS12,
return &tlsBundle{}, nil
}
cert, err := tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath)
if err != nil {
return nil, err
return nil, fmt.Errorf("loading TLS keypair (tls_cert_path=%q, tls_key_path=%q): %w",
h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath, err)
}
tlsConfig.Certificates[0] = cert
tlsConfig := &tls.Config{
NextProtos: []string{"http/1.1"},
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
return tlsConfig, nil
return &tlsBundle{Config: tlsConfig}, nil
}
func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {