mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-05 04:59:26 +09:00
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
This commit is contained in:
1 parent
dcc01dc2b0
commit
5d15cc930d
13 files changed
+639
-134
No files matched your search
+147
-84
@@ -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{}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+21
-14
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
HTTPShutdownTimeout = 3 * time.Second
|
||||
TLSALPN01ChallengeType = "TLS-ALPN-01"
|
||||
HTTP01ChallengeType = "HTTP-01"
|
||||
DNS01ChallengeType = "DNS-01"
|
||||
|
||||
JSONLogFormat = "json"
|
||||
TextLogFormat = "text"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user