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