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:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent dcc01dc2b0
commit 5d15cc930d
13 files changed
+639 -134

No files matched your search

+6 -3
View File
@@ -245,7 +245,7 @@ tls:
hostname: "" hostname: ""
# Type of ACME challenge to use, currently supported types: # 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 challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a # 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. # DNS zone the provider writes to. Defaults to base_domain.
# zone: example.com # 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: challenge:
# How long to wait for a record to show up on every authoritative # How long to wait for a record to show up on every authoritative
# nameserver. 0 disables the check. # nameserver. 0 disables the check.
@@ -386,7 +387,9 @@ dns:
# acmedns needs a registration per certificate name under config, and # acmedns needs a registration per certificate name under config, and
# each _acme-challenge name CNAMEd to its registration's fulldomain. A # each _acme-challenge name CNAMEd to its registration's fulldomain. A
# node without one gets no certificate. A registration holds two # 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: # provider:
# name: acmedns # name: acmedns
# config: # config:
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"vendor": { "vendor": {
"goModSum": "sha256-uRy4uneBtIBymQ6tXa4e8NqFKeePPlo5vAQksLjJjZ4=", "goModSum": "sha256-ldpSNiMpVjREyupJXETxHR7DdDnLK55l6+J6jcePneM=",
"sri": "sha256-D+t9VtKxtuyHYtdhxyF34wqnNpObObl7H3E/wjcXTKU=" "sri": "sha256-USke6o4yKfpvGOW3hW4zG1LA+MRBvBw8YoftPwmOCxk="
} }
} }
+8
View File
@@ -4,6 +4,7 @@ go 1.27.2
require ( require (
github.com/arl/statsviz v0.8.2 github.com/arl/statsviz v0.8.2
github.com/caddyserver/certmagic v0.25.6
github.com/cenkalti/backoff/v5 v5.0.3 github.com/cenkalti/backoff/v5 v5.0.3
github.com/chasefleming/elem-go v0.36.0 github.com/chasefleming/elem-go v0.36.0
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
@@ -31,6 +32,7 @@ require (
github.com/libdns/libdns v1.1.1 github.com/libdns/libdns v1.1.1
github.com/libdns/powerdns v0.1.4 github.com/libdns/powerdns v0.1.4
github.com/libdns/rfc2136 v1.0.1 github.com/libdns/rfc2136 v1.0.1
github.com/mholt/acmez/v3 v3.1.7
github.com/miekg/dns v1.1.73 github.com/miekg/dns v1.1.73
github.com/oapi-codegen/runtime v1.7.0 github.com/oapi-codegen/runtime v1.7.0
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 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/squibble v0.0.0-20260831233316-9a4c8961e6f5
github.com/tailscale/tailsql v0.0.0-20261014183241-aae04fb51be7 github.com/tailscale/tailsql v0.0.0-20261014183241-aae04fb51be7
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e 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 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e
@@ -118,6 +121,7 @@ require (
github.com/axiomhq/hyperloglog v0.2.6 // indirect github.com/axiomhq/hyperloglog v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f // 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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.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/kamstrup/intmap v0.5.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.20.1 // 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/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect
github.com/letsencrypt/challtestsrv v1.4.2 // indirect github.com/letsencrypt/challtestsrv v1.4.2 // indirect
github.com/lib/pq v1.12.3 // 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/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // 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/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // 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 v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.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/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace 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/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
+20
View File
@@ -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/keyboard v0.2.10/go.mod h1:ap/z5ilnhLqYq852m6kPeTq5Z6aESGWu5mzRpJlC6aI=
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= 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 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= 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/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 h1:jdXGSp5Ge5/kzikuxGOw9+6HSStUjkNBJZjGNtpItek=
github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f/go.mod h1:oiCPXxBoDKSkb/RGEZwAZk8y/fwDlQ1vYlBbNRxPysw= 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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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= 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/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 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ=
github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= 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 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 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 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 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/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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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= 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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+147 -84
View File
@@ -1,7 +1,6 @@
package hscontrol package hscontrol
import ( import (
"bytes"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
@@ -11,7 +10,6 @@ import (
"crypto/x509/pkix" "crypto/x509/pkix"
"errors" "errors"
"fmt" "fmt"
"io"
"math/big" "math/big"
"net" "net"
"net/http" "net/http"
@@ -26,6 +24,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/caddyserver/certmagic"
"github.com/cenkalti/backoff/v5" "github.com/cenkalti/backoff/v5"
"github.com/davecgh/go-spew/spew" "github.com/davecgh/go-spew/spew"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -43,11 +42,11 @@ import (
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/util" "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/pkg/profile"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/sasha-s/go-deadlock" "github.com/sasha-s/go-deadlock"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/tailcfg" "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 // Only start debug/metrics server if address is configured
var debugHTTPServer *http.Server var debugHTTPServer *http.Server
@@ -947,6 +953,10 @@ func (h *Headscale) Serve() error {
tlsBundle.ACMEListener.Close() tlsBundle.ACMEListener.Close()
} }
if tlsBundle.Stop != nil {
tlsBundle.Stop()
}
if h.challenges != nil { if h.challenges != nil {
info("removing pending ACME challenge records") info("removing pending ACME challenge records")
h.challenges.Close() //nolint:contextcheck // each delete has its own bounded context h.challenges.Close() //nolint:contextcheck // each delete has its own bounded context
@@ -1019,6 +1029,138 @@ type tlsBundle struct {
Config *tls.Config Config *tls.Config
ACMEServer *http.Server ACMEServer *http.Server
ACMEListener net.Listener 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) { 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 != "" { if h.cfg.TLS.ACME.Hostname != "" {
certManager := autocert.Manager{ return h.acmeTLSSettings(ctx)
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
}
} }
if h.cfg.TLS.CertPath == "" { if h.cfg.TLS.CertPath == "" {
@@ -1252,35 +1344,6 @@ func (h *Headscale) StartEphemeralGCForTest(tb testing.TB) {
tb.Cleanup(func() { h.ephemeralGC.Close() }) 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] // [zerologRequestLogger] implements chi's [middleware.LogFormatter]
// to route HTTP request logs through zerolog. // to route HTTP request logs through zerolog.
type zerologRequestLogger struct{} type zerologRequestLogger struct{}
-27
View File
@@ -2,13 +2,11 @@ package hscontrol
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestSecurityHeaders(t *testing.T) { 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, "nosniff", h.Get("X-Content-Type-Options"))
assert.Equal(t, "no-referrer", h.Get("Referrer-Policy")) 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))
}
+269
View File
@@ -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")
}
})
}
}
+9
View File
@@ -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 // 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. // it with value; for a CNAME, the chain's last name takes fqdn's place.
// A timeout of zero disables the check. // A timeout of zero disables the check.
+21 -14
View File
@@ -278,7 +278,7 @@ var legacyTLSKeys = map[string]string{
var tlsDefaults = map[string]string{ var tlsDefaults = map[string]string{
"tls.acme.challenge_type": HTTP01ChallengeType, "tls.acme.challenge_type": HTTP01ChallengeType,
"tls.acme.http_listen": ":http", "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 // warnLegacyTLSKeys points at the tls keys for flat keys in the config
@@ -749,11 +749,12 @@ func validateServerConfigInto(v *configValidator) {
validateListenerCollisions(v) 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{ v.Add(&ConfigError{
Reason: "tls.acme.challenge_type has an unsupported value", Reason: "tls.acme.challenge_type has an unsupported value",
Current: []KV{{"tls.acme.challenge_type", ct}}, 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", 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 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"), ".") base := strings.TrimSuffix(viper.GetString("dns.base_domain"), ".")
if base != strings.ToLower(base) || dnsname.ValidHostname(base) != nil { if base != strings.ToLower(base) || dnsname.ValidHostname(base) != nil {
v.Add(&ConfigError{ v.Add(&ConfigError{
@@ -1017,6 +1022,8 @@ func dnsChallengeConfig() (DNSChallengeConfig, error) {
return cfg, err 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") { if iso, ok := provider.(hsdns.Isolator); ok && viper.GetBool("dns.certificates.enabled") {
err = iso.CheckIsolation() err = iso.CheckIsolation()
if err != nil { if err != nil {
+54 -4
View File
@@ -1199,6 +1199,47 @@ dns:
`, `,
wantErr: "desktop.ts.example.com and laptop.ts.example.com share registration reg", 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", name: "enabled without magic dns",
yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates: yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates:
@@ -1215,7 +1256,7 @@ dns:
yaml: base + ` certificates: yaml: base + ` certificates:
enabled: true 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", name: "base_domain outside zone",
@@ -1252,6 +1293,15 @@ dns:
`, `,
wantErr: "dns.base_domain must be a lowercase DNS name", 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", name: "negative propagation timeout",
yaml: base + ` challenge: yaml: base + ` challenge:
@@ -1337,7 +1387,7 @@ dns:
want: TLSConfig{ACME: ACMEConfig{ want: TLSConfig{ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType, ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http", HTTPListen: ":http",
CacheDir: "/var/www/.cache", CacheDir: "/var/lib/headscale/cache",
}}, }},
}, },
{ {
@@ -1372,7 +1422,7 @@ tls:
ACME: ACMEConfig{ ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType, ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http", 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{ ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType, ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http", HTTPListen: ":http",
CacheDir: "/var/www/.cache", CacheDir: "/var/lib/headscale/cache",
}, },
}, },
}, },
+1
View File
@@ -7,6 +7,7 @@ const (
HTTPShutdownTimeout = 3 * time.Second HTTPShutdownTimeout = 3 * time.Second
TLSALPN01ChallengeType = "TLS-ALPN-01" TLSALPN01ChallengeType = "TLS-ALPN-01"
HTTP01ChallengeType = "HTTP-01" HTTP01ChallengeType = "HTTP-01"
DNS01ChallengeType = "DNS-01"
JSONLogFormat = "json" JSONLogFormat = "json"
TextLogFormat = "text" TextLogFormat = "text"
+69
View File
@@ -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
}
}
+33
View File
@@ -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)
}