Files
headscale/hscontrol/app_tls_test.go
T
Kristoffer Dalby 5d15cc930d 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
2026-10-18 20:53:18 +02:00

270 lines
7.4 KiB
Go

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")
}
})
}
}