From 5d15cc930d44eefdfd112622158304ea32056231 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 15 Oct 2026 08:53:45 +0000 Subject: [PATCH] hscontrol: replace autocert with certmagic Adds DNS-01 for headscale's own certificate through dns.challenge.provider, with the same propagation check as node certificates. Storage format changes: first start gets a new account and certificate. Updates #2527 Updates #504 --- config-example.yaml | 9 +- flakehashes.json | 4 +- go.mod | 8 + go.sum | 20 +++ hscontrol/app.go | 231 +++++++++++++++++---------- hscontrol/app_test.go | 27 ---- hscontrol/app_tls_test.go | 269 ++++++++++++++++++++++++++++++++ hscontrol/dns/propagation.go | 9 ++ hscontrol/types/config.go | 35 +++-- hscontrol/types/config_test.go | 58 ++++++- hscontrol/types/const.go | 1 + hscontrol/util/zlog/zap.go | 69 ++++++++ hscontrol/util/zlog/zap_test.go | 33 ++++ 13 files changed, 639 insertions(+), 134 deletions(-) create mode 100644 hscontrol/app_tls_test.go create mode 100644 hscontrol/util/zlog/zap.go create mode 100644 hscontrol/util/zlog/zap_test.go diff --git a/config-example.yaml b/config-example.yaml index bf9c8d4f8..d6a995d2e 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -245,7 +245,7 @@ tls: hostname: "" # Type of ACME challenge to use, currently supported types: - # HTTP-01 or TLS-ALPN-01 + # HTTP-01, TLS-ALPN-01 or DNS-01 (uses dns.challenge) challenge_type: HTTP-01 # When HTTP-01 challenge is chosen, letsencrypt must set up a @@ -366,7 +366,8 @@ dns: # DNS zone the provider writes to. Defaults to base_domain. # zone: example.com - # DNS provider for ACME DNS-01 challenges, used by dns.certificates. + # DNS provider for ACME DNS-01 challenges, used by dns.certificates and + # by tls.acme with challenge_type DNS-01. challenge: # How long to wait for a record to show up on every authoritative # nameserver. 0 disables the check. @@ -386,7 +387,9 @@ dns: # acmedns needs a registration per certificate name under config, and # each _acme-challenge name CNAMEd to its registration's fulldomain. A # node without one gets no certificate. A registration holds two - # pending challenges; more are refused until one expires. + # pending challenges; more are refused until one expires. One account + # (username, password, subdomain and server_url instead of config) + # serves headscale's own certificate only. # provider: # name: acmedns # config: diff --git a/flakehashes.json b/flakehashes.json index 1c2787c95..35753b8a3 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-uRy4uneBtIBymQ6tXa4e8NqFKeePPlo5vAQksLjJjZ4=", - "sri": "sha256-D+t9VtKxtuyHYtdhxyF34wqnNpObObl7H3E/wjcXTKU=" + "goModSum": "sha256-ldpSNiMpVjREyupJXETxHR7DdDnLK55l6+J6jcePneM=", + "sri": "sha256-USke6o4yKfpvGOW3hW4zG1LA+MRBvBw8YoftPwmOCxk=" } } diff --git a/go.mod b/go.mod index f37acf24e..6ace4974b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.2 require ( github.com/arl/statsviz v0.8.2 + github.com/caddyserver/certmagic v0.25.6 github.com/cenkalti/backoff/v5 v5.0.3 github.com/chasefleming/elem-go v0.36.0 github.com/coder/websocket v1.8.15 @@ -31,6 +32,7 @@ require ( github.com/libdns/libdns v1.1.1 github.com/libdns/powerdns v0.1.4 github.com/libdns/rfc2136 v1.0.1 + github.com/mholt/acmez/v3 v3.1.7 github.com/miekg/dns v1.1.73 github.com/oapi-codegen/runtime v1.7.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 @@ -51,6 +53,7 @@ require ( github.com/tailscale/squibble v0.0.0-20260831233316-9a4c8961e6f5 github.com/tailscale/tailsql v0.0.0-20261014183241-aae04fb51be7 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e + go.uber.org/zap v1.28.0 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb golang.org/x/crypto v0.58.0 golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e @@ -118,6 +121,7 @@ require ( github.com/axiomhq/hyperloglog v0.2.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f // indirect + github.com/caddyserver/zerossl v0.1.6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -173,6 +177,7 @@ require ( github.com/kamstrup/intmap v0.5.2 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.20.1 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect github.com/letsencrypt/challtestsrv v1.4.2 // indirect github.com/lib/pq v1.12.3 // indirect @@ -223,12 +228,15 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect diff --git a/go.sum b/go.sum index 6b92d73ea..47b270aeb 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ atomicgo.dev/keyboard v0.2.10 h1:v7mvUKUZLHIggxULEIuWbT+WkkyQSgdbA201EziAhHU= atomicgo.dev/keyboard v0.2.10/go.mod h1:ap/z5ilnhLqYq852m6kPeTq5Z6aESGWu5mzRpJlC6aI= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= @@ -92,6 +94,10 @@ github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f h1:voy0korWbg2e1g github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4= github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f h1:jdXGSp5Ge5/kzikuxGOw9+6HSStUjkNBJZjGNtpItek= github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f/go.mod h1:oiCPXxBoDKSkb/RGEZwAZk8y/fwDlQ1vYlBbNRxPysw= +github.com/caddyserver/certmagic v0.25.6 h1:vHMtFSLKTa6kuZz6w0SnRMpQ388tSkww8ye355BgAM4= +github.com/caddyserver/certmagic v0.25.6/go.mod h1:xq6cRNimqW+Tv91XNc5lNHs2XYTsLhQhYiH01H4AySs= +github.com/caddyserver/zerossl v0.1.6 h1:1yrhTx5DWi43wOJPQ+Y4mVl++EPmSqqT/4REW//lsas= +github.com/caddyserver/zerossl v0.1.6/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -361,6 +367,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ= github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= +github.com/mholt/acmez/v3 v3.1.7 h1:XTqpuUcRdRoIBuDoI+M4mCnRnPTPN4rqH3Mb723Rg4U= +github.com/mholt/acmez/v3 v3.1.7/go.mod h1:Lwv6P/czh/AOq+c99tAzP68/VP92s+8y+hDs4FCgxvo= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -538,6 +546,12 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= @@ -560,6 +574,12 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/hscontrol/app.go b/hscontrol/app.go index 45be6b543..f0a6c3a19 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -1,7 +1,6 @@ package hscontrol import ( - "bytes" "context" "crypto/ecdsa" "crypto/elliptic" @@ -11,7 +10,6 @@ import ( "crypto/x509/pkix" "errors" "fmt" - "io" "math/big" "net" "net/http" @@ -26,6 +24,7 @@ import ( "testing" "time" + "github.com/caddyserver/certmagic" "github.com/cenkalti/backoff/v5" "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi/v5" @@ -43,11 +42,11 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/util" + "github.com/juanfont/headscale/hscontrol/util/zlog" + "github.com/mholt/acmez/v3/acme" "github.com/pkg/profile" "github.com/rs/zerolog/log" "github.com/sasha-s/go-deadlock" - "golang.org/x/crypto/acme" - "golang.org/x/crypto/acme/autocert" "golang.org/x/sync/errgroup" "tailscale.com/envknob" "tailscale.com/tailcfg" @@ -813,6 +812,13 @@ func (h *Headscale) Serve() error { }) } + if tlsBundle.Manage != nil { + err := tlsBundle.Manage(ctx) + if err != nil { + return fmt.Errorf("managing TLS certificate: %w", err) + } + } + // Only start debug/metrics server if address is configured var debugHTTPServer *http.Server @@ -947,6 +953,10 @@ func (h *Headscale) Serve() error { tlsBundle.ACMEListener.Close() } + if tlsBundle.Stop != nil { + tlsBundle.Stop() + } + if h.challenges != nil { info("removing pending ACME challenge records") h.challenges.Close() //nolint:contextcheck // each delete has its own bounded context @@ -1019,6 +1029,138 @@ type tlsBundle struct { Config *tls.Config ACMEServer *http.Server ACMEListener net.Listener + + // Manage starts obtaining and renewing the certificate. It must run + // after the listeners are bound: the ACME library binds a challenge + // port itself when nothing serves it yet. Nil without ACME. + Manage func(context.Context) error + // Stop ends certificate maintenance. Nil without ACME. + Stop func() +} + +// acmeTrustedRoots verifies the ACME directory; nil means the system roots. +// Tests point it at their CA. +var acmeTrustedRoots *x509.CertPool + +// acmeTLSSettings gets headscale's own certificate with certmagic, which +// keeps it renewed. DNS-01 publishes the challenge through the same DNS +// provider as node certificates. +func (h *Headscale) acmeTLSSettings(ctx context.Context) (*tlsBundle, error) { + acmeCfg := h.cfg.TLS.ACME + logger := zlog.Zap(log.Logger).Named("acme") + + // An empty CA means Let's Encrypt, and certmagic then retries failures + // against Let's Encrypt staging to spare the production rate limits. + issuer := certmagic.ACMEIssuer{ + CA: acmeCfg.URL, + Email: acmeCfg.Email, + Agreed: true, + TrustedRoots: acmeTrustedRoots, + Logger: logger, + } + + var err error + + switch acmeCfg.ChallengeType { + case types.DNS01ChallengeType: + issuer.DNS01Solver = dns01Solver{ + DNS01Solver: &certmagic.DNS01Solver{ + DNSProvider: h.cfg.DNSChallenge.Provider, + TTL: dns.ChallengeTTL, + Resolvers: h.cfg.DNSChallenge.Resolvers, + Logger: logger, + }, + wait: dns.AwaitTXT(h.cfg.DNSChallenge.Resolvers, h.cfg.DNSChallenge.PropagationTimeout), + } + + case types.TLSALPN01ChallengeType: + // Validated on the main listener, which must be reachable on port 443. + issuer.DisableHTTPChallenge = true + issuer.ListenHost, _, _ = net.SplitHostPort(h.cfg.Addr) + + issuer.AltTLSALPNPort, err = types.PortFromAddr(h.cfg.Addr) + if err != nil { + return nil, fmt.Errorf("parsing listen_addr: %w", err) + } + + case types.HTTP01ChallengeType: + // TLS-ALPN-01 stays on, as with autocert: setups that only open + // port 443 have always been validated that way. + issuer.AltTLSALPNPort, err = types.PortFromAddr(h.cfg.Addr) + if err != nil { + return nil, fmt.Errorf("parsing listen_addr: %w", err) + } + + issuer.ListenHost, _, _ = net.SplitHostPort(acmeCfg.HTTPListen) + + issuer.AltHTTPPort, err = types.PortFromAddr(acmeCfg.HTTPListen) + if err != nil { + return nil, fmt.Errorf("parsing tls.acme.http_listen: %w", err) + } + + default: + return nil, errUnsupportedLetsEncryptChallengeType + } + + var magic *certmagic.Config + + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { return magic, nil }, + Logger: logger, + }) + magic = certmagic.New(cache, certmagic.Config{ + Storage: &certmagic.FileStorage{Path: acmeCfg.CacheDir}, + Logger: logger, + }) + + bundle := &tlsBundle{ + Manage: func(ctx context.Context) error { return magic.ManageAsync(ctx, []string{acmeCfg.Hostname}) }, + Stop: cache.Stop, + } + + acmeIssuer := certmagic.NewACMEIssuer(magic, issuer) + magic.Issuers = []certmagic.Issuer{acmeIssuer} + + bundle.Config = magic.TLSConfig() + bundle.Config.NextProtos = append([]string{"h2", "http/1.1"}, bundle.Config.NextProtos...) + bundle.Config.MinVersion = tls.VersionTLS12 + + if acmeCfg.ChallengeType == types.HTTP01ChallengeType { + bundle.ACMEServer = &http.Server{ + Addr: acmeCfg.HTTPListen, + Handler: acmeIssuer.HTTPChallengeHandler(http.HandlerFunc(h.redirect)), + ReadTimeout: types.HTTPTimeout, + } + + bundle.ACMEListener, err = (&net.ListenConfig{}).Listen(ctx, "tcp", acmeCfg.HTTPListen) + if err != nil { + cache.Stop() + + return nil, &types.ListenerBindError{ + Listener: "ACME HTTP-01 challenge", + YAMLKey: "tls.acme.http_listen", + Addr: acmeCfg.HTTPListen, + Err: err, + } + } + } + + return bundle, nil +} + +// dns01Solver checks propagation as for node certificates instead of the +// way certmagic does: certmagic accepts the first nameserver serving the +// value and follows only the first CNAME of a delegated challenge. +type dns01Solver struct { + *certmagic.DNS01Solver + + wait func(ctx context.Context, fqdn, value string) error +} + +// Wait runs before the CA is asked to validate. When it fails, the ACME +// client cleans the challenge up. +func (s dns01Solver) Wait(ctx context.Context, ch acme.Challenge) error { + return s.wait(ctx, ch.DNS01TXTRecordName()+".", ch.DNS01KeyAuthorization()) } func (h *Headscale) getTLSSettings(ctx context.Context) (*tlsBundle, error) { @@ -1030,57 +1172,7 @@ func (h *Headscale) getTLSSettings(ctx context.Context) (*tlsBundle, error) { } if h.cfg.TLS.ACME.Hostname != "" { - certManager := autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(h.cfg.TLS.ACME.Hostname), - Cache: autocert.DirCache(h.cfg.TLS.ACME.CacheDir), - Client: &acme.Client{ - DirectoryURL: h.cfg.TLS.ACME.URL, - HTTPClient: &http.Client{ - Transport: &acmeLogger{ - rt: http.DefaultTransport, - }, - }, - }, - Email: h.cfg.TLS.ACME.Email, - } - - switch h.cfg.TLS.ACME.ChallengeType { - case types.TLSALPN01ChallengeType: - // 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 &tlsBundle{Config: certManager.TLSConfig()}, nil - - case types.HTTP01ChallengeType: - // Configuration via autocert with HTTP-01. This requires listening on - // port 80 for the certificate validation in addition to the headscale - // service, which can be configured to run on any other port. - server := &http.Server{ - Addr: h.cfg.TLS.ACME.HTTPListen, - Handler: certManager.HTTPHandler(http.HandlerFunc(h.redirect)), - ReadTimeout: types.HTTPTimeout, - } - - listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", server.Addr) - if err != nil { - return nil, &types.ListenerBindError{ - Listener: "ACME HTTP-01 challenge", - YAMLKey: "tls.acme.http_listen", - Addr: server.Addr, - Err: err, - } - } - - return &tlsBundle{ - Config: certManager.TLSConfig(), - ACMEServer: server, - ACMEListener: listener, - }, nil - - default: - return nil, errUnsupportedLetsEncryptChallengeType - } + return h.acmeTLSSettings(ctx) } if h.cfg.TLS.CertPath == "" { @@ -1252,35 +1344,6 @@ func (h *Headscale) StartEphemeralGCForTest(tb testing.TB) { tb.Cleanup(func() { h.ephemeralGC.Close() }) } -// Provide some middleware that can inspect the ACME/autocert https calls -// and log when things are failing. -type acmeLogger struct { - rt http.RoundTripper -} - -// RoundTrip will log when ACME/autocert failures happen either when err != nil OR -// when http status codes indicate a failure has occurred. -func (l *acmeLogger) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := l.rt.RoundTrip(req) - if err != nil { - log.Error().Err(err).Str("url", req.URL.String()).Msg("acme request failed") - return nil, err - } - - if resp.StatusCode >= http.StatusBadRequest { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - log.Error().Int("status_code", resp.StatusCode).Str("url", req.URL.String()).Bytes("body", body).Msg("acme request returned error") - - // The ACME client parses this body to classify errors such as badNonce, - // so give it back a readable copy. - resp.Body = io.NopCloser(bytes.NewReader(body)) - } - - return resp, nil -} - // [zerologRequestLogger] implements chi's [middleware.LogFormatter] // to route HTTP request logs through zerolog. type zerologRequestLogger struct{} diff --git a/hscontrol/app_test.go b/hscontrol/app_test.go index 609569328..75397b5f9 100644 --- a/hscontrol/app_test.go +++ b/hscontrol/app_test.go @@ -2,13 +2,11 @@ package hscontrol import ( "context" - "io" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestSecurityHeaders(t *testing.T) { @@ -26,28 +24,3 @@ func TestSecurityHeaders(t *testing.T) { assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options")) assert.Equal(t, "no-referrer", h.Get("Referrer-Policy")) } - -func TestACMELoggerKeepsErrorBodyReadable(t *testing.T) { - const problem = `{"type":"urn:ietf:params:acme:error:badNonce","detail":"JWS has an invalid anti-replay nonce","status":400}` - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(problem)) - })) - defer server.Close() - - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) - require.NoError(t, err) - - resp, err := (&acmeLogger{rt: http.DefaultTransport}).RoundTrip(req) - require.NoError(t, err) - - defer resp.Body.Close() - - // acme parses the body to classify errors such as badNonce, so it has to - // survive the logging middleware. - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.JSONEq(t, problem, string(body)) -} diff --git a/hscontrol/app_tls_test.go b/hscontrol/app_tls_test.go new file mode 100644 index 000000000..b62d446b6 --- /dev/null +++ b/hscontrol/app_tls_test.go @@ -0,0 +1,269 @@ +package hscontrol + +import ( + "context" + "crypto/tls" + "crypto/x509" + "log" + "net" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/caddyserver/certmagic" + "github.com/juanfont/headscale/hscontrol/dns/dnstest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/letsencrypt/pebble/v2/ca" + "github.com/letsencrypt/pebble/v2/db" + "github.com/letsencrypt/pebble/v2/va" + "github.com/letsencrypt/pebble/v2/wfe" + "github.com/mholt/acmez/v3/acme" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + acmeTestZone = "example.com." + acmeTestHost = "headscale.example.com" +) + +// testACME is an in-process ACME CA (Pebble) with an authoritative DNS +// server for acmeTestZone. The DNS server answers TXT from provider, so a +// DNS-01 record published through it is what the CA validates. +type testACME struct { + directory string + resolver string + roots *x509.CertPool + provider *dnstest.Provider +} + +// startTestACME validates HTTP-01 on httpPort and TLS-ALPN-01 on tlsPort +// of 127.0.0.1, where acmeTestHost resolves to. +func startTestACME(t *testing.T, httpPort, tlsPort int) *testACME { + t.Helper() + + // Validate at once and deterministically. + t.Setenv("PEBBLE_VA_NOSLEEP", "1") + t.Setenv("PEBBLE_WFE_NONCEREJECT", "0") + t.Setenv("PEBBLE_AUTHZREUSE", "0") + + provider := &dnstest.Provider{} + resolver := startZoneServer(t, provider) + + logger := log.New(os.Stderr, "pebble ", log.LstdFlags) + store := db.NewMemoryStore() + authority := ca.New(logger, store, "", "ecdsa", 0, 1, map[string]ca.Profile{ + "default": {Description: "default"}, + }) + validator := va.New(logger, httpPort, tlsPort, false, resolver, store) + acme := wfe.New(logger, store, validator, authority, []string{"pebble.letsencrypt.org"}, false, false, 0, 0) + + srv := httptest.NewTLSServer(acme.Handler()) + t.Cleanup(srv.Close) + + directoryRoots := x509.NewCertPool() + directoryRoots.AddCert(srv.Certificate()) + + prev := acmeTrustedRoots + acmeTrustedRoots = directoryRoots + + t.Cleanup(func() { acmeTrustedRoots = prev }) + + roots := x509.NewCertPool() + roots.AddCert(authority.GetRootCert(0).Cert) + + return &testACME{ + directory: srv.URL + wfe.DirectoryPath, + resolver: resolver, + roots: roots, + provider: provider, + } +} + +// startZoneServer serves acmeTestZone over UDP and TCP on one port: +// certmagic finds the zone over UDP, Pebble validates over TCP. +func startZoneServer(t *testing.T, provider *dnstest.Provider) string { + t.Helper() + + handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + q := r.Question[0] + hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Rrtype: q.Qtype, Ttl: 60} + + switch { + case !strings.HasSuffix(strings.ToLower(q.Name), acmeTestZone): + m.Rcode = dns.RcodeRefused + case q.Qtype == dns.TypeSOA && strings.EqualFold(q.Name, acmeTestZone): + m.Answer = []dns.RR{&dns.SOA{ + Hdr: hdr, Ns: "ns." + acmeTestZone, Mbox: "hostmaster." + acmeTestZone, + Serial: 1, Refresh: 60, Retry: 60, Expire: 60, Minttl: 60, + }} + case q.Qtype == dns.TypeA: + m.Answer = []dns.RR{&dns.A{Hdr: hdr, A: net.IPv4(127, 0, 0, 1)}} + case q.Qtype == dns.TypeTXT: + for _, v := range provider.Values(strings.ToLower(q.Name)) { + m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}}) + } + } + + _ = w.WriteMsg(m) + }) + + var lc net.ListenConfig + + pc, err := lc.ListenPacket(t.Context(), "udp", "127.0.0.1:0") + require.NoError(t, err) + + ln, err := lc.Listen(t.Context(), "tcp", pc.LocalAddr().String()) + require.NoError(t, err) + + for _, srv := range []*dns.Server{ + {PacketConn: pc, Handler: handler}, + {Listener: ln, Handler: handler}, + } { + go func() { _ = srv.ActivateAndServe() }() + + t.Cleanup(func() { _ = srv.Shutdown() }) + } + + return pc.LocalAddr().String() +} + +func freePort(t *testing.T) int { + t.Helper() + + var lc net.ListenConfig + + ln, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + _, port, err := net.SplitHostPort(ln.Addr().String()) + require.NoError(t, err) + require.NoError(t, ln.Close()) + + p, err := strconv.Atoi(port) + require.NoError(t, err) + + return p +} + +// TestDNS01SolverWait pins that headscale's own certificate waits for the +// value certmagic published, at the name it published it. +func TestDNS01SolverWait(t *testing.T) { + provider := &dnstest.Provider{} + + var waited []string + + s := dns01Solver{ + DNS01Solver: &certmagic.DNS01Solver{ + DNSProvider: provider, + Resolvers: []string{startZoneServer(t, provider)}, + }, + wait: func(_ context.Context, fqdn, value string) error { + waited = []string{fqdn, value} + + return nil + }, + } + ch := acme.Challenge{ + Type: acme.ChallengeTypeDNS01, + KeyAuthorization: "token.thumbprint", + Identifier: acme.Identifier{Type: "dns", Value: acmeTestHost}, + } + + require.NoError(t, s.Present(t.Context(), ch)) + require.NoError(t, s.Wait(t.Context(), ch)) + + fqdn := "_acme-challenge." + acmeTestHost + "." + + require.Len(t, waited, 2) + assert.Equal(t, fqdn, waited[0]) + assert.Equal(t, provider.Values(fqdn), waited[1:]) +} + +// TestACMETLSSettings obtains headscale's own certificate from Pebble with +// each challenge type, serving it the way [Headscale.Serve] does. +func TestACMETLSSettings(t *testing.T) { + for _, challenge := range []string{ + types.DNS01ChallengeType, + types.HTTP01ChallengeType, + types.TLSALPN01ChallengeType, + } { + t.Run(challenge, func(t *testing.T) { + httpPort, tlsPort := freePort(t), freePort(t) + acme := startTestACME(t, httpPort, tlsPort) + + h := &Headscale{cfg: &types.Config{ + ServerURL: "https://" + acmeTestHost, + Addr: net.JoinHostPort("127.0.0.1", strconv.Itoa(tlsPort)), + TLS: types.TLSConfig{ACME: types.ACMEConfig{ + Hostname: acmeTestHost, + ChallengeType: challenge, + HTTPListen: net.JoinHostPort("127.0.0.1", strconv.Itoa(httpPort)), + URL: acme.directory, + CacheDir: t.TempDir(), + }}, + DNSChallenge: types.DNSChallengeConfig{ + Provider: acme.provider, + Resolvers: []string{acme.resolver}, + }, + }} + + bundle, err := h.getTLSSettings(t.Context()) + require.NoError(t, err) + t.Cleanup(bundle.Stop) + + ln, err := tls.Listen("tcp", h.cfg.Addr, bundle.Config) + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + + go func() { + defer conn.Close() + + _ = conn.(*tls.Conn).HandshakeContext(t.Context()) //nolint:forcetypeassert // tls listener + }() + } + }() + + if bundle.ACMEServer != nil { + go func() { _ = bundle.ACMEServer.Serve(bundle.ACMEListener) }() + + t.Cleanup(func() { _ = bundle.ACMEServer.Close() }) + } + + require.NoError(t, bundle.Manage(t.Context())) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + var d tls.Dialer + + d.Config = &tls.Config{ServerName: acmeTestHost, RootCAs: acme.roots, MinVersion: tls.VersionTLS12} + + conn, err := d.DialContext(t.Context(), "tcp", h.cfg.Addr) + if assert.NoError(c, err) { + _ = conn.Close() + } + }, 30*time.Second, 200*time.Millisecond, "headscale should serve a certificate from the ACME CA") + + if challenge == types.DNS01ChallengeType { + assert.NotEmpty(t, acme.provider.Calls(), "DNS-01 must publish through the DNS provider") + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.Empty(c, acme.provider.Values("_acme-challenge."+acmeTestHost+".")) + }, 10*time.Second, 100*time.Millisecond, "challenge record should be removed") + } + }) + } +} diff --git a/hscontrol/dns/propagation.go b/hscontrol/dns/propagation.go index e5052480f..c5e1c6ea0 100644 --- a/hscontrol/dns/propagation.go +++ b/hscontrol/dns/propagation.go @@ -46,6 +46,15 @@ func newTXTChecker(resolvers []string, timeout time.Duration) txtChecker { } } +// AwaitTXT returns a wait that returns once every authoritative +// nameserver serves a TXT value, checked as for node certificates. It is +// for headscale's own certificate: certmagic's check accepts the first +// nameserver serving the value and follows only the first CNAME of a +// delegated challenge. +func AwaitTXT(resolvers []string, timeout time.Duration) func(ctx context.Context, fqdn, value string) error { + return newTXTChecker(resolvers, timeout).wait +} + // wait returns nil once each nameserver of the zone holding fqdn answers // it with value; for a CNAME, the chain's last name takes fqdn's place. // A timeout of zero disables the check. diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index fbcdd4484..6712222e8 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -278,7 +278,7 @@ var legacyTLSKeys = map[string]string{ var tlsDefaults = map[string]string{ "tls.acme.challenge_type": HTTP01ChallengeType, "tls.acme.http_listen": ":http", - "tls.acme.cache_dir": "/var/www/.cache", + "tls.acme.cache_dir": "/var/lib/headscale/cache", } // warnLegacyTLSKeys points at the tls keys for flat keys in the config @@ -749,11 +749,12 @@ func validateServerConfigInto(v *configValidator) { validateListenerCollisions(v) - if ct := tlsKey("tls.acme.challenge_type"); ct != HTTP01ChallengeType && ct != TLSALPN01ChallengeType { + if ct := tlsKey("tls.acme.challenge_type"); ct != HTTP01ChallengeType && + ct != TLSALPN01ChallengeType && ct != DNS01ChallengeType { v.Add(&ConfigError{ Reason: "tls.acme.challenge_type has an unsupported value", Current: []KV{{"tls.acme.challenge_type", ct}}, - Allowed: []string{HTTP01ChallengeType, TLSALPN01ChallengeType}, + Allowed: []string{HTTP01ChallengeType, TLSALPN01ChallengeType, DNS01ChallengeType}, Hint: "pick one of the allowed values; HTTP-01 is the default", }) } @@ -935,7 +936,21 @@ func validateCertificatesConfig(v *configValidator) { }) } - if !viper.GetBool("dns.certificates.enabled") { + enabled := viper.GetBool("dns.certificates.enabled") + challengeType := tlsKey("tls.acme.challenge_type") + ownDNS01 := tlsKey("tls.acme.hostname") != "" && challengeType == DNS01ChallengeType + + if (enabled || ownDNS01) && viper.GetString("dns.challenge.provider.name") == "" { + v.Add(&ConfigError{ + Reason: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider", + Current: []KV{{"dns.certificates.enabled", enabled}, {"tls.acme.challenge_type", challengeType}}, + Allowed: hsdns.ProviderNames(), + Hint: "configure the DNS provider that hosts dns.base_domain or tls.acme.hostname under dns.challenge.provider", + See: dnsDocsURL, + }) + } + + if !enabled { return } @@ -948,16 +963,6 @@ func validateCertificatesConfig(v *configValidator) { }) } - if viper.GetString("dns.challenge.provider.name") == "" { - v.Add(&ConfigError{ - Reason: "dns.certificates requires dns.challenge.provider", - Current: []KV{{"dns.certificates.enabled", true}}, - Allowed: hsdns.ProviderNames(), - Hint: "configure the DNS provider that hosts dns.base_domain under dns.challenge.provider", - See: dnsDocsURL, - }) - } - base := strings.TrimSuffix(viper.GetString("dns.base_domain"), ".") if base != strings.ToLower(base) || dnsname.ValidHostname(base) != nil { v.Add(&ConfigError{ @@ -1017,6 +1022,8 @@ func dnsChallengeConfig() (DNSChallengeConfig, error) { return cfg, err } + // Only node certificates let a client pick what is published; a record + // shared by headscale's own certificate alone is harmless. if iso, ok := provider.(hsdns.Isolator); ok && viper.GetBool("dns.certificates.enabled") { err = iso.CheckIsolation() if err != nil { diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 59c594599..bb2740ae6 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -1199,6 +1199,47 @@ dns: `, wantErr: "desktop.ts.example.com and laptop.ts.example.com share registration reg", }, + { + name: "acmedns one account for headscale's own certificate", + yaml: base + ` challenge: + provider: + name: acmedns + username: user + password: pass + subdomain: reg + server_url: https://auth.acme-dns.io +tls: + acme: + hostname: headscale.example.com + challenge_type: DNS-01 +`, + wantZone: "ts.example.com", + }, + { + name: "acmedns registration shared with headscale's own name", + yaml: base + ` certificates: + enabled: true + challenge: + provider: + name: acmedns + config: + laptop.ts.example.com: + username: user + password: pass + subdomain: reg + server_url: https://auth.acme-dns.io + headscale.example.com: + username: user + password: pass + subdomain: reg + server_url: https://auth.acme-dns.io +tls: + acme: + hostname: headscale.example.com + challenge_type: DNS-01 +`, + wantErr: "headscale.example.com and laptop.ts.example.com share registration reg", + }, { name: "enabled without magic dns", yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates: @@ -1215,7 +1256,7 @@ dns: yaml: base + ` certificates: enabled: true `, - wantErr: "dns.certificates requires dns.challenge.provider", + wantErr: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider", }, { name: "base_domain outside zone", @@ -1252,6 +1293,15 @@ dns: `, wantErr: "dns.base_domain must be a lowercase DNS name", }, + { + name: "tls DNS-01 without provider", + yaml: base + `tls: + acme: + hostname: headscale.example.com + challenge_type: DNS-01 +`, + wantErr: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider", + }, { name: "negative propagation timeout", yaml: base + ` challenge: @@ -1337,7 +1387,7 @@ dns: want: TLSConfig{ACME: ACMEConfig{ ChallengeType: HTTP01ChallengeType, HTTPListen: ":http", - CacheDir: "/var/www/.cache", + CacheDir: "/var/lib/headscale/cache", }}, }, { @@ -1372,7 +1422,7 @@ tls: ACME: ACMEConfig{ ChallengeType: HTTP01ChallengeType, HTTPListen: ":http", - CacheDir: "/var/www/.cache", + CacheDir: "/var/lib/headscale/cache", }, }, }, @@ -1404,7 +1454,7 @@ tls_letsencrypt_cache_dir: /var/lib/headscale/.cache ACME: ACMEConfig{ ChallengeType: HTTP01ChallengeType, HTTPListen: ":http", - CacheDir: "/var/www/.cache", + CacheDir: "/var/lib/headscale/cache", }, }, }, diff --git a/hscontrol/types/const.go b/hscontrol/types/const.go index ba15df528..1b75c89d9 100644 --- a/hscontrol/types/const.go +++ b/hscontrol/types/const.go @@ -7,6 +7,7 @@ const ( HTTPShutdownTimeout = 3 * time.Second TLSALPN01ChallengeType = "TLS-ALPN-01" HTTP01ChallengeType = "HTTP-01" + DNS01ChallengeType = "DNS-01" JSONLogFormat = "json" TextLogFormat = "text" diff --git a/hscontrol/util/zlog/zap.go b/hscontrol/util/zlog/zap.go new file mode 100644 index 000000000..024c22c43 --- /dev/null +++ b/hscontrol/util/zlog/zap.go @@ -0,0 +1,69 @@ +package zlog + +import ( + "slices" + + "github.com/rs/zerolog" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Zap returns a [zap.Logger] that writes to logger, for libraries that +// only log through zap. +func Zap(logger zerolog.Logger) *zap.Logger { + return zap.New(zapCore{logger: logger}) +} + +type zapCore struct { + logger zerolog.Logger + fields []zapcore.Field +} + +func (c zapCore) Enabled(level zapcore.Level) bool { + l := zerologLevel(level) + + return l >= c.logger.GetLevel() && l >= zerolog.GlobalLevel() +} + +func (c zapCore) With(fields []zapcore.Field) zapcore.Core { + return zapCore{logger: c.logger, fields: slices.Concat(c.fields, fields)} +} + +func (c zapCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(entry.Level) { + return checked.AddCore(entry, c) + } + + return checked +} + +func (c zapCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { + enc := zapcore.NewMapObjectEncoder() + for _, f := range slices.Concat(c.fields, fields) { + f.AddTo(enc) + } + + c.logger.WithLevel(zerologLevel(entry.Level)). + Str("logger", entry.LoggerName). + Fields(enc.Fields). + Msg(entry.Message) + + return nil +} + +func (zapCore) Sync() error { return nil } + +// zerologLevel maps zap levels to zerolog. Levels above error are logged +// as errors: zerolog's WithLevel never panics or exits. +func zerologLevel(level zapcore.Level) zerolog.Level { + switch { + case level <= zapcore.DebugLevel: + return zerolog.DebugLevel + case level == zapcore.InfoLevel: + return zerolog.InfoLevel + case level == zapcore.WarnLevel: + return zerolog.WarnLevel + default: + return zerolog.ErrorLevel + } +} diff --git a/hscontrol/util/zlog/zap_test.go b/hscontrol/util/zlog/zap_test.go new file mode 100644 index 000000000..b733f2444 --- /dev/null +++ b/hscontrol/util/zlog/zap_test.go @@ -0,0 +1,33 @@ +package zlog + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestZap(t *testing.T) { + var buf bytes.Buffer + + logger := Zap(zerolog.New(&buf).Level(zerolog.InfoLevel)). + Named("acme"). + With(zap.String("issuer", "letsencrypt")) + + logger.Debug("below the logger's level") + logger.Warn("renewing", zap.Int("attempt", 2)) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got), buf.String()) + assert.Equal(t, map[string]any{ + "level": "warn", + "logger": "acme", + "issuer": "letsencrypt", + "attempt": float64(2), + "message": "renewing", + }, got) +}