mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-21 05:56:34 +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
+6
-3
@@ -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:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-uRy4uneBtIBymQ6tXa4e8NqFKeePPlo5vAQksLjJjZ4=",
|
||||
"sri": "sha256-D+t9VtKxtuyHYtdhxyF34wqnNpObObl7H3E/wjcXTKU="
|
||||
"goModSum": "sha256-ldpSNiMpVjREyupJXETxHR7DdDnLK55l6+J6jcePneM=",
|
||||
"sri": "sha256-USke6o4yKfpvGOW3hW4zG1LA+MRBvBw8YoftPwmOCxk="
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+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