Compare commits

...

309 commits

Author SHA1 Message Date
Ludovic Fernandez
87b172f103
gigahostno: remove unused Zone fields (#2913) 2026-03-12 21:27:46 +01:00
exsesa
9be8cd43ae
Add DNS provider for Excedo (#2910)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2026-03-10 22:58:13 +01:00
Dane
7b1aa50081
safedns: rename UKFast SafeDNS to ANS SafeDNS (#2877)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2026-03-08 23:02:14 +00:00
Ludovic Fernandez
a56697ed1c
Add DNS provider for EuroDNS (#2898) 2026-03-08 23:32:56 +01:00
Ludovic Fernandez
847c763504
feat: Add DNS provider for Czechia (#2885) 2026-03-04 18:04:04 +01:00
Fernandez Ludovic
da51631cd3
chore: improve issue template 2026-03-04 17:59:58 +01:00
Ludovic Fernandez
491dcaad1d
feat: allow to Unwrap obtainError (#2874) 2026-02-25 23:12:43 +01:00
Ludovic Fernandez
7d459b59c5
liara: add support for team ID (#2867) 2026-02-22 14:14:18 +01:00
Fernandez Ludovic
4547c4317e
Detach v4.32.0 2026-02-19 13:14:28 +01:00
Fernandez Ludovic
c50918c54e
Prepare release v4.32.0 2026-02-19 13:14:13 +01:00
Ludovic Fernandez
dd1ea80c08
Add DNS provider for Leaseweb (#2856) 2026-02-19 12:52:04 +01:00
Ludovic Fernandez
078a1889c8
Add DNS provider for ArtFiles (#2859) 2026-02-19 12:26:53 +01:00
Ludovic Fernandez
d896c1f036
fix: preserve domain order (#2862) 2026-02-19 12:25:10 +01:00
Ludovic Fernandez
84f3be40f0
chore: update dependencies (#2860) 2026-02-18 23:26:31 +01:00
Ludovic Fernandez
94e3bfb96a
chore: update linter (#2857) 2026-02-17 23:34:27 +01:00
Ludovic Fernandez
c06f378f0e
namesurfer: fix updateDNSHost (#2854) 2026-02-15 10:42:48 +01:00
Grigas Šukys
2e095b95a5
Add DNS provider for FusionLayer NameSurfer (#2852)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2026-02-15 02:22:35 +00:00
Ludovic Fernandez
4a61728ff0
fix: deduplicate authz for DNS01 challenge (#2828) 2026-02-12 19:13:49 +01:00
Ludovic Fernandez
1991339cc1
timewebcloud: fix subdomain support (#2845) 2026-02-12 18:20:05 +01:00
Ludovic Fernandez
dc51d5ee65
chore: use memcache Docker image directly (#2846) 2026-02-12 18:01:57 +01:00
Fernandez Ludovic
4c6d29882e
chore: update linter, and workflows 2026-02-12 17:32:23 +01:00
Andy Warner
c1aaf19aac
docs: make it more clear that any ACME CA may be used (#2841) 2026-02-10 02:51:54 +01:00
Mortie Torabi
fac5c39f5f
fix: implement parsing for Retry-After header according to RFC 7231 (#2830)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2026-01-30 20:36:46 +01:00
Ludovic Fernandez
a7145a29ac
fix: use IPs to define the main domain (#2817) 2026-01-28 18:41:23 +01:00
Ludovic Fernandez
2ce04a6586
alidns: add line record option (#2814) 2026-01-25 14:51:12 +01:00
Fernandez Ludovic
44b89b7e92
allinkl: factorize findZone 2026-01-22 05:10:35 +01:00
Ludovic Fernandez
16894fb99e
allinkl: detect zone through API (#2721) 2026-01-20 17:59:42 +01:00
Ludovic Fernandez
de869c8a7e
Add DNS provider for Bluecat v2 (#2791) 2026-01-19 17:31:56 +01:00
Ameer Ghani
05333f3c84
chore: improve warning message about backups (#2797) 2026-01-16 20:13:42 +00:00
Ludovic Fernandez
4d41c52db8
Add DNS provider for DDNSS (#2795) 2026-01-15 23:16:02 +01:00
Ludovic Fernandez
d063b15c02
azure: reinforces deprecation (#2792) 2026-01-15 01:04:30 +01:00
Ludovic Fernandez
527d51d485
Add DNS provider for DNSExit (#2787) 2026-01-12 17:04:28 +00:00
Ludovic Fernandez
7f10c131f4
Add DNS provider for TodayNIC/时代互联 (#2788) 2026-01-12 17:50:21 +01:00
Fernandez Ludovic
ac1092710d Detach v4.31.0 2026-01-08 17:58:41 +01:00
Fernandez Ludovic
9f3dde3f6d Prepare release v4.31.0 2026-01-08 17:58:41 +01:00
Ludovic Fernandez
b77b8709b6
namedotcom: follow CNAME (#2390) 2026-01-08 17:34:37 +01:00
Ludovic Fernandez
eed3f0dcc8
chore: update linter (#2785) 2026-01-08 17:33:57 +01:00
Ludovic Fernandez
b7a9b7dad0
chore: update dependencies (#2783) 2026-01-08 17:33:11 +01:00
Ludovic Fernandez
dd6ab7ca95
Add DNS provider for JDCloud (#2782) 2026-01-07 18:03:32 +01:00
Ludovic Fernandez
c5a259564f
Add DNS provider for 35.com/三五互联 (#2779) 2026-01-06 19:06:05 +01:00
Ludovic Fernandez
4783c128fa
chore: minor changes (#2776) 2026-01-05 00:05:16 +01:00
Ludovic Fernandez
2eede6d620
hetzner: fix compatibility with _FILE suffix (#2775) 2026-01-01 22:11:38 +01:00
Ludovic Fernandez
1b634097c1
docs: remove email from examples (#2773) 2025-12-29 18:33:53 +01:00
Ludovic Fernandez
a6a73754af
Add DNS provider for Alwaysdata (#2770) 2025-12-29 14:09:06 +01:00
Ludovic Fernandez
ff885d99c2
gandiv5: fix API Key header (#2769) 2025-12-23 16:39:06 +01:00
Ludovic Fernandez
ee616417a1
f5xc: add an option to configure the domain of the server (#2767) 2025-12-23 13:52:22 +01:00
Simon Merschjohann
8b327005b3
Add DNS Provider for ISPConfig (DDNS Module) (#2760)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-12-22 03:50:29 +01:00
Ludovic Fernandez
96168f78de
Add DNS provider for ISPConfig (#2762) 2025-12-21 22:55:28 +01:00
Ludovic Fernandez
bb98b9a899
chore: update pebble to v2.9.0 (#2763) 2025-12-19 02:27:38 +01:00
Ludovic Fernandez
a5cc0e1555
feat: improve ACME error types (#2761) 2025-12-18 14:29:52 +01:00
Fernandez Ludovic
7af0efdf72 Detach v4.30.1 2025-12-16 21:35:22 +01:00
Fernandez Ludovic
27075d562a Prepare release v4.30.1 2025-12-16 21:35:22 +01:00
Ludovic Fernandez
5574de68cd
fix: downgrade aliyun credentials to v1.4.7 (#2756) 2025-12-16 19:33:30 +01:00
Ludovic Fernandez
43dc1aa835
chore: fix attest-build-provenance subject-checksums path (#2755) 2025-12-16 19:04:42 +01:00
Fernandez Ludovic
222cd85cbc Detach v4.30.0 2025-12-16 18:07:20 +01:00
Fernandez Ludovic
4e6426cb2e Prepare release v4.30.0 2025-12-16 18:07:20 +01:00
Ludovic Fernandez
5b30df22b5
chore: update dependencies (#2753) 2025-12-16 17:20:51 +01:00
Karl Fritsche
e21ba75da8
Add DNS provider for Ionos Cloud (#2752)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-12-15 23:45:24 +01:00
Fernandez Ludovic
bb5e70a4e5 docs: improve contributing guide 2025-12-15 23:34:25 +01:00
Fernandez Ludovic
a6e6b92d35 chore: clean maps 2025-12-15 23:22:16 +01:00
Ludovic Fernandez
465d7918a8
Add DNS provider for hosting.nl (#1967) 2025-12-15 20:16:31 +01:00
Fernandez Ludovic
c59d163e79
chore: improves github templates 2025-12-11 14:16:34 +01:00
Ludovic Fernandez
e54598536b
Add DNS provider for Virtualname (#2748) 2025-12-11 12:58:21 +01:00
Adrian
9e2dffe8d2
Add DNS Provider for Neodigit (#2747)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-12-10 21:52:23 +01:00
Fernandez Ludovic
1e57e29a9d
chore: skip jekyll 2025-12-09 16:32:14 +01:00
Fernandez Ludovic
961fd586d9
docs: add notes 2025-12-09 16:25:30 +01:00
Ludovic Fernandez
02dd7152f0
Add DNS provider for Syse.no (#2742) 2025-12-08 20:25:08 +01:00
Ludovic Fernandez
36552da309
chore: update workflows (#2741) 2025-12-05 16:45:51 +01:00
Ludovic Fernandez
dea97e4dfa
Add DNS provider for Gravity (#2738) 2025-12-01 21:37:20 +01:00
Ludovic Fernandez
bc163db9ed
feat: remove email requirement (#2736) 2025-12-01 20:55:41 +01:00
Ludovic Fernandez
ea97ce2f62
chore: move provider "manual" into a dedicated package (#2739) 2025-12-01 20:51:43 +01:00
Ludovic Fernandez
cc83c025b5
autodns: use the right response structure (#2737) 2025-12-01 20:50:46 +01:00
Fernandez Ludovic
742741fe05 Detach v4.29.0 2025-11-29 14:49:25 +01:00
Fernandez Ludovic
5488fdf856 Prepare release v4.29.0 2025-11-29 14:49:25 +01:00
Fernandez Ludovic
fc5e0174b8
docs: update the number of supported DNS 2025-11-29 14:28:34 +01:00
Fernandez Ludovic
3f2ebf7ef1
chore: improve issue templates 2025-11-29 14:28:08 +01:00
Ludovic Fernandez
1757cdeaee
chore: use common implementations of the providers instead of the API clients (#2734) 2025-11-29 14:20:42 +01:00
Ludovic Fernandez
dc0a595a9f
Add DNS provider for United-Domains (#2731) 2025-11-27 20:40:55 +01:00
Ludovic Fernandez
42fb4346e2
chore: update dependencies (#2733) 2025-11-27 20:40:23 +01:00
Ludovic Fernandez
ad6adbffd4
tests: fix flaky test (#2729) 2025-11-25 19:30:22 +01:00
Ludovic Fernandez
56cb356ef2
edgeone: add zones mapping (#2728) 2025-11-25 19:29:47 +01:00
Ludovic Fernandez
aea6afe2d6
Add DNS provider for Gigahost.no (#2723) 2025-11-24 18:44:43 +01:00
Ludovic Fernandez
93b8bb71ca
hetzner: use int64 for IDs (#2720) 2025-11-22 01:11:55 +01:00
Fernandez Ludovic
0abf391bd1
docs: remove author names 2025-11-19 01:27:40 +01:00
Fernandez Ludovic
57c14f8d2a
chore: add pull request template 2025-11-14 18:35:15 +01:00
Ludovic Fernandez
ea8aca4366
Add DNS provider for AlibabaCloud ESA (#2703) 2025-11-14 14:33:54 +01:00
Ludovic Fernandez
a8226a6713
namecheap: add experimental proxy support (#2715) 2025-11-14 14:12:57 +01:00
RHQYZ
b338263c96
baiducloud: pagination and TTL (#2712)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-11-11 13:36:35 +01:00
Evgeniy Medvedev
877738cef3
Add DNS provider for EdgeCenter (#2710)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-11-09 20:56:16 +01:00
Ludovic Fernandez
1c33fba180
chore: add major version tag for Docker images (#2709) 2025-11-08 13:38:05 +01:00
oliverbr
d5dc3866e6
gandiv5: update base API URL (#2708) 2025-11-08 02:29:10 +00:00
Fernandez Ludovic
22955739a1 Detach v4.28.1 2025-11-06 17:53:18 +01:00
Fernandez Ludovic
b704b26e6c Prepare release v4.28.1 2025-11-06 17:53:18 +01:00
Ludovic Fernandez
14778cc1f1
fix: skip nil response (#2705) 2025-11-06 13:19:09 +01:00
Fernandez Ludovic
e12c9fc637 Detach v4.28.0 2025-10-31 11:44:04 +01:00
Fernandez Ludovic
102f7067ac Prepare release v4.28.0 2025-10-31 11:44:04 +01:00
Ludovic Fernandez
591116b3a4
webnames: rename to webnamesru to avoid ambiguity with webnamesca (#2700) 2025-10-30 20:57:17 +00:00
Ludovic Fernandez
7d099f2ad7
Add DNS provider for webnames.ca (#2698) 2025-10-30 21:41:06 +01:00
Ludovic Fernandez
31772ec503
chore: update dependencies (#2695) 2025-10-30 14:18:27 +01:00
Ludovic Fernandez
81e0f2b42a
chore: update linter (#2699) 2025-10-30 13:02:35 +01:00
Ludovic Fernandez
5dba10703f
iwantmyname: provider deprecation (#2694) 2025-10-29 18:39:54 +00:00
Ludovic Fernandez
da8280ac49
chore: add debug transport on DNS API clients (#2692)
Co-authored-by: Dominik Menke <git@dmke.org>
2025-10-29 18:18:38 +00:00
Ludovic Fernandez
12dc42accf
chore: update vegadns client (#2691) 2025-10-28 17:56:58 +01:00
CzBiX
5ea0509b86
docs: update name and links for Profiles Extension RFC (#2689) 2025-10-27 11:43:19 +01:00
Ludovic Fernandez
4bb17b0234
hostinger: fix record update (#2690) 2025-10-27 11:42:02 +01:00
Ludovic Fernandez
fe0a1f8668
hetzner: add deprecation logs (#2683)
Co-authored-by: Dominik Menke <git@dmke.org>
2025-10-23 21:06:17 +02:00
René
e6c98a195e
Add DNS provider for Anexia (#2675)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-10-23 11:57:29 +02:00
Ludovic Fernandez
95953b45b5
chore: improve retryable HTTP client error handling (#2682) 2025-10-22 20:22:12 +02:00
Fernandez Ludovic
9e7572e1ed
chore: fix typos inside issue templates 2025-10-20 22:34:12 +02:00
Ludovic Fernandez
4022827c6e
chore: remove existing files before generating new files (#2676) 2025-10-18 16:46:55 +02:00
Fernandez Ludovic
b44293d8b1
chore: fix attest-build-provenance checksums subject 2025-10-17 17:25:07 +02:00
Fernandez Ludovic
753d31f254 Detach v4.27.0 2025-10-17 13:38:14 +02:00
Fernandez Ludovic
a58c45ee4f Prepare release v4.27.0 2025-10-17 13:38:14 +02:00
Fernandez Ludovic
acfb5ea938 docs: sponsor incentives 2025-10-16 21:59:48 +02:00
Fernandez Ludovic
0fcac851b3 docs: improve changelog headings 2025-10-16 21:59:48 +02:00
Ludovic Fernandez
2eb76de5a0
chore: update dependencies (#2673) 2025-10-16 20:49:25 +02:00
Fernandez Ludovic
8873a5539c
chore: minor changes 2025-10-15 23:59:46 +02:00
Ludovic Fernandez
526ca7395c
chore: replace wait.For with backoff inside DNS providers (#2671) 2025-10-15 18:26:51 +02:00
Ludovic Fernandez
07683e60d8
chore: update to github.com/cenkalti/backoff/v5 (#2668) 2025-10-15 13:42:00 +02:00
Ludovic Fernandez
f0c314c3ef
hetzner: update to new API (#2663) 2025-10-14 21:56:06 +02:00
Ludovic Fernandez
213d7b8fa3
chore: wait.For stop with error (#2665) 2025-10-13 12:15:13 +02:00
Ludovic Fernandez
a3f3c620e9
Add DNS provider for Octenium (#2661) 2025-10-07 16:41:10 +02:00
Ludovic Fernandez
7a6aa1110a
chore: update release workflow (#2657) 2025-09-29 18:06:38 +02:00
cfif-31
621d9d0d0e
Add DNS provider for Beget.com (#1879)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-09-29 11:36:53 +02:00
Ludovic Fernandez
8249f73fa2
fix: deduplicate order identifiers (#2656) 2025-09-28 21:15:41 +02:00
bartjanssens92
26920e75f7
otc: add example (#2655)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-09-23 16:36:13 +02:00
Ludovic Fernandez
95eb44ccbe
hostinger: fix Present (#2654) 2025-09-23 12:22:54 +02:00
Philip Kannegaard Hayes
ba156d5344
feat: support --private-key with a PKCS#8 keypair (#2653) 2025-09-22 23:33:24 +02:00
Ludovic Fernandez
bb33817a61
chore: update linter (#2652) 2025-09-22 21:32:25 +02:00
Ludovic Fernandez
f432d2141e
Add DNS provider for Hostinger (#2651) 2025-09-22 17:56:19 +02:00
Nikita Shashkov
bf0e89cdd9
otc: adds option to use private zone (#2649)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-09-17 14:39:50 +02:00
Fernandez Ludovic
0d567188f6 Detach v4.26.0 2025-09-13 13:33:15 +02:00
Fernandez Ludovic
42f057cc72 Prepare release v4.26.0 2025-09-13 13:33:15 +02:00
Ludovic Fernandez
e76933536e
Add DNS provider for KeyHelp (#2642) 2025-09-13 13:07:40 +02:00
Ludovic Fernandez
bfe7df489b
chore: update dependencies (#2644) 2025-09-13 13:06:59 +02:00
DerLinkman
f4bd48e672
servercow: updated API documentation link (#2643) 2025-09-12 10:17:23 +00:00
Ludovic Fernandez
de8959229d
chore: update dependencies (#2640) 2025-09-11 17:51:36 +02:00
Ludovic Fernandez
6bfc090680
selectelv2: add missing options (#2639) 2025-09-07 23:30:00 +02:00
Ludovic Fernandez
2308cd4778
feat(EAB): fallback to base64.URLEncoding (#2635) 2025-09-05 15:35:49 +02:00
Ludovic Fernandez
5c1e21308c
chore: update documentation theme (#2632) 2025-08-28 23:44:26 +02:00
Ludovic Fernandez
cb44524070
simply: update to API v2 (#2631) 2025-08-28 17:04:43 +02:00
Ludovic Fernandez
784ce2be95
oraclecloud: add aliases (#2627) 2025-08-24 22:18:26 +02:00
Ludovic Fernandez
50a24ced37
Add DNS provider for Binary Lane (#2624) 2025-08-21 13:19:26 +02:00
Ludovic Fernandez
8a11af149f
azuredns: pipeline credential support (#2621) 2025-08-19 17:44:47 +02:00
Ludovic Fernandez
0012e20e52
tests: new DNS router/server/mock (#2613) 2025-08-08 18:28:50 +02:00
Ludovic Fernandez
1904d17e89
chore: bump alidns from v4.55.10 to v4.55.11 (#2601) 2025-08-06 18:11:54 +02:00
Ludovic Fernandez
ddce5cff4a
Add DNS provider for Tencent EdgeOne (#2606) 2025-08-06 17:00:24 +02:00
Ludovic Fernandez
0ec467f075
bump: github.com/akamai/AkamaiOPEN-edgegrid-golang to v11 (#2524) 2025-08-06 16:54:29 +02:00
Cleiton Nunes
8521cbc977
oraclecloud: handle instance_principal authentication (#2599)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-08-06 16:54:15 +02:00
Fernandez Ludovic
f37aaa788c Detach v4.25.2 2025-08-06 15:24:42 +02:00
Fernandez Ludovic
8737b36c85 Prepare release v4.25.2 2025-08-06 15:24:42 +02:00
Ludovic Fernandez
fc21d23f7f
tests: clean up code (#2612) 2025-08-04 14:46:37 +02:00
Ludovic Fernandez
756d5ade0e
tests: change the signature of the method BuildHTTPS (#2611) 2025-08-04 10:21:14 +00:00
Ludovic Fernandez
c9157f756e
chore: clean up (#2610) 2025-08-02 11:41:35 +00:00
Ludovic Fernandez
4d2dc64364
tests: simplify fake DNS server (rfc2136) (#2609) 2025-08-02 13:07:45 +02:00
Ludovic Fernandez
238454b5f7
fix: enforce HTTPS to the ACME server (#2608) 2025-08-01 16:35:07 +02:00
Ludovic Fernandez
b4ddc1e5e2
tests: use better test domains (#2603) 2025-07-28 09:26:40 +02:00
Ludovic Fernandez
605d49d500
chore: clean up tests and code (#2602) 2025-07-26 11:16:01 +02:00
Ludovic Fernandez
137ad86fa4
fix: remove wrong env var (#2600) 2025-07-25 22:56:41 +02:00
bllfr0g
c689b20fee
feat: log when dynamic renew date not yet reached (#2597)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-07-23 00:47:44 +02:00
Fernandez Ludovic
8a75475894 Detach v4.25.1 2025-07-21 19:49:23 +02:00
Fernandez Ludovic
6f54d60599 Prepare release v4.25.1 2025-07-21 19:49:23 +02:00
Ludovic Fernandez
833d3b8147
fix: wrong CLI flag type (#2595) 2025-07-21 14:26:53 +00:00
Fernandez Ludovic
f54baa83ac Detach v4.25.0 2025-07-21 14:25:55 +02:00
Fernandez Ludovic
ced6669dcd Prepare release v4.25.0 2025-07-21 14:25:55 +02:00
Ludovic Fernandez
793f65fed9
chore: update dependencies (#2592) 2025-07-21 10:56:25 +00:00
Ludovic Fernandez
cb602702d2
huaweicloud: lightweight client (#2591)
Co-authored-by: Dominik Menke <git@dmke.org>
2025-07-18 17:03:47 +00:00
Ludovic Fernandez
79f496e11c
alidns: replace alidns-20150109 with a fork (#2589) 2025-07-17 20:15:01 +00:00
Ludovic Fernandez
d0008c42f5
tencentcloud: replace tencentcloud-sdk-go with a fork (#2588) 2025-07-17 21:55:47 +02:00
Ludovic Fernandez
7d82b83bfd
chore: use custom API client constructor for sakuracloud (#2587) 2025-07-17 15:38:48 +00:00
Ludovic Fernandez
0eac4b3dda
tests: improve function naming (#2586) 2025-07-15 21:09:01 +02:00
Ludovic Fernandez
8b40479678
chore: migrate to yandex cloud API Client v2 (#2585) 2025-07-15 14:24:45 +02:00
Ludovic Fernandez
1742e6d0ae
chore: replace official Civo API client by an internal API client (#2584) 2025-07-13 16:27:36 +00:00
Ludovic Fernandez
a8e19ef7f3
chore: replace official Cloudflare API client by an internal API client (#2583) 2025-07-13 18:09:26 +02:00
Ludovic Fernandez
b0e3fd2682
chore: check generated files (#2582) 2025-07-12 15:10:25 +02:00
Ludovic Fernandez
08e9db687b
chore: minor changes (#2581) 2025-07-12 14:31:03 +02:00
Ludovic Fernandez
b8beddc267
Add DNS provider for ZoneEdit (#2578) 2025-07-12 12:20:02 +00:00
Ludovic Fernandez
52e167c930
test: server client mock (#2571) 2025-07-12 13:57:15 +02:00
Victor Hugo
fae73fdc5d
vinyldns: add an option to add quotes around the TXT record value (#2580)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-07-11 20:34:25 +00:00
Ludovic Fernandez
bfa487cc48
fix: enforce domain into renewal command (#2576) 2025-07-10 18:14:03 +02:00
Ludovic Fernandez
96b18d764d
feat: add option to define dynamically the renew date (#2574)
Co-authored-by: Dominik Menke <git@dmke.org>
2025-07-10 12:13:19 +02:00
Ludovic Fernandez
713acefd7f
chore: update to go1.24 (#2566) 2025-07-08 17:23:52 +02:00
Ludovic Fernandez
40baed291c
feat: add option to disable common name in CSR (#2570) 2025-07-08 17:23:31 +02:00
Ludovic Fernandez
d9bba80a19
ionos: increase default propagation timeout (#2569) 2025-07-08 17:23:13 +02:00
Fernandez Ludovic
d1c79386e1 Detach v4.24.0 2025-07-07 20:27:44 +02:00
Fernandez Ludovic
14d66f0c20 Prepare release v4.24.0 2025-07-07 20:27:44 +02:00
Marcus Grando
17c65de6e7
azion: improve zone lookup (#2564)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-07-07 19:48:57 +02:00
Ludovic Fernandez
990f9ac601
mijnhost: improve record filter (#2562) 2025-07-05 12:27:48 +02:00
Ludovic Fernandez
1fecd31d3d
alidns: migrate to SDK v2 (#2558) 2025-07-03 18:17:33 +02:00
Ludovic Fernandez
94d871230d
oraclecloud: replace oci-go-sdk by a modular fork (#2556) 2025-07-03 18:13:08 +02:00
Marcus Grando
b28d1ac67a
azion: add pagination support (#2555)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-07-02 22:44:09 +02:00
Ludovic Fernandez
08316e47a6
googledomains: provider deprecation (#2554) 2025-07-02 22:03:17 +02:00
Ludovic Fernandez
9531f9e9c9
chore: update linter (#2552) 2025-07-01 17:10:27 +02:00
Marcus Grando
6ecdde77f0
Add DNS provider for Azion (#2550)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-06-30 03:30:00 +02:00
George Melikov
45790d3c68
chore: update link to create issue about new provider (#2549) 2025-06-28 15:01:49 +02:00
Andrew Johnson
8d7ed798a7
gcloud: add service account impersonation (#2544)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-06-23 15:22:55 +02:00
Léo Colombaro
a528e280f9
docs: update reference ACME ARI RFC 9773 in place of the draft (#2541) 2025-06-19 14:31:33 +00:00
Joel Strasser
7571c0bd31
Add DNS provider for DynDnsFree.de (#2540)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-06-19 13:33:38 +02:00
mlec
375300f969
exoscale: fix find record (#2535)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-06-08 23:17:45 +02:00
Benjamin Schwarze
f05362515a
nicmanager: fix mode env var name and value (#2534)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-06-06 12:49:46 +02:00
Andrew Imeson
476f9ed910
docs(cPanel): fix examples (#2529) 2025-05-19 20:22:10 +00:00
Ludovic Fernandez
e9a255df9b
pdns: improve error messages (#2526) 2025-05-09 20:27:42 +02:00
Ludovic Fernandez
2f10624b11
chore: bump dnsimple, namedotcom, and selectel to major versions (#2523) 2025-05-08 13:13:48 +02:00
Anton Dzyk
65608d8bbf
Add DNS provider for RU Center (#1892)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-05-05 21:11:00 +00:00
msshtdev
b82e6d88e4
Add DNS provider for ConoHa v3 (#2516)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-05-05 20:45:56 +00:00
Gregor Bigalke
d6df946223
cloudflare: add quotation marks to TXT record (#2521)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-05-05 00:33:10 +02:00
Ludovic Fernandez
1cee2efbdc
fix: check order identifiers difference between client and server (#2520) 2025-04-29 13:56:59 +02:00
Ludovic Fernandez
c56d45486b
chore: stream command output for e2e tests (#2513) 2025-04-23 22:31:19 +02:00
Fernandez Ludovic
950d4a0201 Detach v4.23.1 2025-04-16 16:38:39 +02:00
Fernandez Ludovic
42c37d3779 Prepare release v4.23.1 2025-04-16 16:38:39 +02:00
Fernandez Ludovic
ca25f1c83a Detach v4.23.0 2025-04-16 13:21:08 +02:00
Fernandez Ludovic
ffaa64a88b Prepare release v4.23.0 2025-04-16 13:21:08 +02:00
Fernandez Ludovic
da2aad2215 chore: use a fixed version of goreleaser
Because of a regression about AUR inside v2.8.2
2025-04-16 13:21:08 +02:00
Ludovic Fernandez
b2faa73e23
chore: update linter (#2507) 2025-04-16 12:46:23 +02:00
Ludovic Fernandez
fcc64f0068
Add DNS provider for Axelname (#2495) 2025-04-14 19:29:36 +02:00
Ludovic Fernandez
3b9653beec
Add DNS provider for Baidu Cloud (#2505) 2025-04-14 19:03:14 +02:00
fries1234
3f795d6ab1
pdns: fix TXT record cleanup for wildcard domains (#2500)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-03-30 23:49:24 +00:00
Ludovic Fernandez
24a46d0c15
feat: add delay option for TLSALPN challenge (#2499) 2025-03-27 12:04:02 +00:00
Sebastian Lohff
627e6e2c35
designate: speed up API requests by using filters (#2498) 2025-03-26 17:14:15 +00:00
Ludovic Fernandez
ba7b4bcf11
chore: update linter (#2492) 2025-03-25 15:05:26 +01:00
Ludovic Fernandez
dc8a3390ae
chore: update dependencies (#2491) 2025-03-23 20:04:50 +01:00
Ludovic Fernandez
0fae2f0511
allinkl: remove ReturnInfo (#2490) 2025-03-20 15:27:23 +00:00
Ludovic Fernandez
f4d47c8606
route53: adds option to use private zone (#2162) 2025-03-19 12:56:07 +00:00
Crys
e57af854f1
cloudflare: make base URL configurable (#2484)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-03-17 22:02:45 +01:00
Crys
937f83c92c
chore: fix feature request template (#2485) 2025-03-16 00:41:35 +01:00
Daniel McCarney
eb48c607ad
tests: compare RSA priv keys ignoring precomputed (#2481) 2025-03-15 12:26:09 +00:00
Ludovic Fernandez
51aaf75afb
tests: change ns (#2482) 2025-03-15 13:08:01 +01:00
Ludovic Fernandez
a8693c1aea
fix: retry on alreadyReplaced error (#2475) 2025-03-12 20:31:00 +01:00
Ludovic Fernandez
46420fef71
websupport: migrate to API v2 (#2479) 2025-03-11 23:13:45 +01:00
Ludovic Fernandez
730af10596
Add DNS provider for Active24 (#2478) 2025-03-11 17:54:28 +01:00
Ludovic Fernandez
2bc147f58a
chore: related timer with context.Done (#2471)
Co-authored-by: Dominik Menke <git@dmke.org>
2025-03-05 14:07:13 +00:00
Ludovic Fernandez
3b9752b625
chore: update dependencies (#2470) 2025-03-05 13:32:40 +01:00
Ludovic Fernandez
13780562cc
fix: kill hook when the command is stuck (#2469) 2025-03-05 13:32:23 +01:00
Ludovic Fernandez
c8aa9920ea
dnssimple: use GetZone (#2467) 2025-03-05 13:32:06 +01:00
Ludovic Fernandez
4675ef7d9a
Add DNS provider for F5 XC (#2409) 2025-03-03 18:01:11 +01:00
Hideki Okamoto
5b06dd7874
edgedns: add account switch key option (#2460)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-03-03 02:57:26 +01:00
Ludovic Fernandez
fe10c3ab3c
infoblox: update API client to v2 (#2459) 2025-02-27 12:30:13 +01:00
Ludovic Fernandez
da260e45b0
feat: add INFOBLOX_CA_CERTIFICATE option (#2458) 2025-02-26 23:41:29 +01:00
Fernandez Ludovic
b31c6ce79b docs: this is not the API URL for Porkbun 2025-02-26 20:48:08 +01:00
Martijn van Hoof
55b012ba06
Add DNS provider for Metaregistrar (#2455)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-02-24 20:15:16 +00:00
Fernandez Ludovic
9d7e2a8c44 chore: update issue templates 2025-02-24 15:24:28 +01:00
Ludovic Fernandez
d8c11a8cf5
Add DNS provider for BookMyName (#2316) 2025-02-23 21:48:19 +01:00
Ludovic Fernandez
f1afe52251
fix: malformed log messages (#2452) 2025-02-23 21:02:11 +01:00
Ludovic Fernandez
0ab907c183
chore: use go1.23 (#2446) 2025-02-18 20:10:57 +01:00
Fernandez Ludovic
526ac35e5c Detach v4.22.2 2025-02-17 20:47:35 +01:00
Fernandez Ludovic
dca1090bb5 Prepare release v4.22.2 2025-02-17 20:47:35 +01:00
Fernandez Ludovic
c0c8bef783 chore: update goreleaser configuration 2025-02-17 20:47:35 +01:00
Ludovic Fernandez
584d374714
acme-dns: use new registred account (#2445) 2025-02-17 20:37:45 +01:00
Fernandez Ludovic
d183572e93 Detach v4.22.1 2025-02-17 16:04:25 +01:00
Fernandez Ludovic
5a02346226 Prepare release v4.22.1 2025-02-17 16:04:25 +01:00
Ludovic Fernandez
f9c1e241f3
acme-dns: continue the process when the CNAME is handled by the storage (#2443) 2025-02-17 16:00:22 +01:00
Fernandez Ludovic
c34138845e Detach v4.22.0 2025-02-17 13:51:20 +01:00
Fernandez Ludovic
9b7343cb42 Prepare release v4.22.0 2025-02-17 13:51:20 +01:00
Ludovic Fernandez
29cf89ea49
acme-dns: fix file path (#2439) 2025-02-16 22:39:44 +01:00
Ludovic Fernandez
b16da88eb7
acme-dns: allow the HTTP storage server to create the CNAME (#2437) 2025-02-16 16:12:47 +01:00
Ludovic Fernandez
5d7fd6621e
chore: update linter (#2434) 2025-02-12 12:58:46 +01:00
Ludovic Fernandez
c96a165aa9
feat(cli): add an option to set the private key (#2431) 2025-02-10 12:54:02 +01:00
Ludovic Fernandez
a25218dbb8
Add DNS provider for Spaceship (#2406) 2025-02-09 22:12:26 +01:00
Ludovic Fernandez
c0260c1d8a
fix: rewrite status management (#2428) 2025-02-09 22:08:43 +01:00
Ali Najmabadizadeh
4552d03a4d
chore: update links of liara provider API doc (#2424) 2025-02-06 19:09:00 +00:00
Ludovic Fernandez
4349dfc5eb
feat: option to set CSR emails (#2423) 2025-02-06 19:00:45 +01:00
Ludovic Fernandez
e644196bfc
feat: add delay option for HTTP challenge (#2422) 2025-02-05 23:52:48 +01:00
Ludovic Fernandez
7cd008e80a
feat(cli): add LEGO_DEBUG_ACME_HTTP_CLIENT to debug the calls to the ACME server (#2420) 2025-02-04 13:43:51 +01:00
bllfr0g
dc992b8d87
feat: add support for Profiles Extension (#2415)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-01-29 08:55:36 +01:00
Ludovic Fernandez
2e497ca928
fix(cli): remove extra debug logs (#2412) 2025-01-28 18:02:51 +01:00
Ludovic Fernandez
7d7bc7b044
Add DNS provider for myaddr.{tools,dev,io} (#2411) 2025-01-28 18:02:12 +01:00
Fernandez Ludovic
2211b56fea chore: update issue template 2025-01-15 13:56:51 +01:00
Ludovic Fernandez
248e775788
chore: use constants for env vars related to flags (#2399) 2025-01-13 12:21:31 +01:00
dmayle
b7947d83c5
cli: add environment variable for specifying email (#2398)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-01-13 01:32:20 +00:00
Ludovic Fernandez
5f69695771
docs: improve units and default values (#2397) 2025-01-11 15:35:31 +01:00
Ludovic Fernandez
4c65680b7a
netcup: remove TTL option (#2396) 2025-01-10 15:17:25 +00:00
Ludovic Fernandez
c2b88e19da
acme-dns: HTTP storage (#2393) 2025-01-09 22:12:05 +01:00
bossm8
b83c1d5f64
feat: add hook-timeout to run and renew commands (#2389)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2025-01-03 14:22:00 +00:00
Ludovic Fernandez
5f53d3e87d
chore: update linter (#2387) 2025-01-02 22:59:02 +00:00
Fernandez Ludovic
ee7a9e4fa0 Detach v4.21.0 2024-12-20 16:10:55 +01:00
Fernandez Ludovic
258fb88ec9 Prepare release v4.21.0 2024-12-20 16:10:55 +01:00
Ludovic Fernandez
bcc17b1bf8
chore: update dependencies (#2383) 2024-12-20 15:07:58 +01:00
Ludovic Fernandez
52e711e049
Add DNS provider for ManageEngine CloudDNS (#2365) 2024-12-20 13:49:38 +01:00
Ludovic Fernandez
bfe3606793
chore: update dependencies (#2381) 2024-12-12 00:03:29 +01:00
Ludovic Fernandez
eac62e3037
netcup: increase default propagation values (#2379) 2024-12-10 14:22:49 +00:00
Ludovic Fernandez
65250372ee
fix(cli): use retryable client for ACME server calls (#2368) 2024-12-10 15:02:07 +01:00
Ludovic Fernandez
0bbf5ab59c
chore: shared deref and pointer functions (#2376) 2024-12-05 19:49:13 +01:00
Ludovic Fernandez
1a62bbab40
bunny: fix zone detection (#2375) 2024-12-05 14:24:23 +01:00
Jan-Henrik Bruhn
2c13835084
inwx: delete only the TXT record related to the DNS challenge (#2373)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2024-12-03 18:51:37 +00:00
Ludovic Fernandez
eb041044b8
fix(cli): create client only when needed (#2372) 2024-12-03 14:03:49 +01:00
Ludovic Fernandez
aacfa2b069
infomaniak: increase default propagation timeout (#2371) 2024-12-03 14:03:26 +01:00
Ludovic Fernandez
19a02023b4
fix(cli): clone the transport with tls-skip-verify (#2369) 2024-12-01 16:29:02 +01:00
Nick J Lange
c2f179f144
docs: add note about --dns.resolvers (#2364)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2024-11-29 15:06:30 +01:00
Ludovic Fernandez
8e5448ccd7
otc: use default transport (#2363) 2024-11-27 14:42:03 +01:00
Ludovic Fernandez
2c42b264d0
dnsmadeeasy: use default transport (#2362) 2024-11-27 14:34:46 +01:00
Lucas Savva
abccd21e75
feat: add --force-cert-domains flag to renew (#2355) 2024-11-26 00:29:35 +01:00
Fernandez Ludovic
87b7e7191f chore: fix AUR configuration 2024-11-22 02:58:37 +01:00
Ludovic Fernandez
b34902160d
Add DNS provider for West.cn/西部数码 (#2318) 2024-11-21 16:47:07 +00:00
Cikaros
6fccca616a
Add DNS provider for Rainyun/雨云 (#2354)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2024-11-21 17:28:38 +01:00
Fernandez Ludovic
645169e3e5 Detach v4.20.4 2024-11-21 16:07:48 +01:00
Fernandez Ludovic
3fc9ae13e6 Prepare release v4.20.4 2024-11-21 16:07:48 +01:00
Fernandez Ludovic
b66d768d64 chore: publish snap to the stable channel 2024-11-21 16:07:48 +01:00
Fernandez Ludovic
d5290e9834 Detach v4.20.3 2024-11-21 14:34:14 +01:00
Fernandez Ludovic
b38be9150b Prepare release v4.20.3 2024-11-21 14:34:14 +01:00
Ludovic Fernandez
7d83daef46
technitium: fix status code handling (#2357) 2024-11-21 13:23:18 +01:00
Ludovic Fernandez
8ed8207007
chore: publish aur lego-bin (#2356) 2024-11-21 00:17:36 +01:00
Ludovic Fernandez
a628db57d9
chore: check DNSProvider interface (#2352) 2024-11-15 23:21:21 +01:00
Ludovic Fernandez
5987820520
directadmin: fix timeout configuration (#2351) 2024-11-15 19:13:12 +01:00
Ludovic Fernandez
15af1079a0
chore: fix snap name (#2349) 2024-11-13 14:52:37 +00:00
Ludovic Fernandez
92d437fb1b
chore: restore snap packaging (#2348) 2024-11-13 14:08:24 +00:00
Fernandez Ludovic
062e355439 docs: fix typos 2024-11-12 00:02:35 +01:00
Ludovic Fernandez
669cf4d21d
docs: improve changelog style (#2346) 2024-11-11 18:45:24 +01:00
Ludovic Fernandez
11929c9c78
fix: HTTP server IPv6 matching (#2345) 2024-11-11 18:45:08 +01:00
Fernandez Ludovic
e0207678be Detach v4.20.2 2024-11-11 13:33:05 +01:00
1753 changed files with 73188 additions and 19280 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
**/zz_gen_*.* linguist-generated
docs/data/zz_cli_help.toml linguist-generated

View file

@ -7,9 +7,9 @@ body:
attributes:
label: Welcome
options:
- label: Yes, I'm using a binary release within 2 latest releases.
- label: Yes, I'm using a binary release within the two latest releases.
required: true
- label: Yes, I've searched similar issues on GitHub and didn't find any.
- label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- label: Yes, I've included all information below (version, config, etc).
required: true
@ -35,6 +35,7 @@ body:
attributes:
label: How do you use lego?
options:
- I don't know
- Library
- Binary
- Docker image
@ -44,6 +45,8 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
- Through Certimate
- go install
- Other
validations:
required: true
@ -64,8 +67,9 @@ body:
- type: textarea
id: version
attributes:
label: Version of lego
label: Effective version of lego
description: |-
`latest` or `dev` are not effective versions.
```console
$ lego --version
```

View file

@ -6,7 +6,7 @@ body:
attributes:
label: Welcome
options:
- label: Yes, I've searched similar issues on GitHub and didn't find any.
- label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- type: dropdown
@ -14,6 +14,7 @@ body:
attributes:
label: How do you use lego?
options:
- I don't know
- Library
- Binary
- Docker image
@ -23,10 +24,20 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
- Through Certimate
- go install
- Other
validations:
required: true
- type: input
id: version
attributes:
label: Effective version of lego
description: "`latest` or `dev` are not effective versions."
validations:
required: true
- type: textarea
id: description
attributes:

View file

@ -8,15 +8,21 @@ body:
attributes:
label: Welcome
options:
- label: Yes, I've searched similar issues on GitHub and didn't find any.
- label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- label: Yes, the DNS provider exposes a public API.
required: true
- label: Yes, I know that the lego maintainers don't have an account in all DNS providers in the world.
required: true
- type: checkboxes
id: pr
attributes:
label: Implementation
options:
- label: Yes, I'm able to create a pull request and be able to maintain the implementation.
required: false
- label: Yes, I'm able to test an implementation if someone creates a pull request to add the support of this DNS provider.
- label: Yes, I can test an implementation with the help of the maintainers if someone creates a pull request.
required: false
- type: dropdown
@ -24,6 +30,7 @@ body:
attributes:
label: How do you use lego?
options:
- I don't know
- Library
- Binary
- Docker image
@ -33,10 +40,23 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
- Through Certimate
- go install
- Other
validations:
required: true
- type: dropdown
id: profile
attributes:
label: Who are you?
options:
- A customer of this DNS provider
- An employee of this DNS provider
- Other (please explain)
validations:
required: true
- type: input
id: provider-link
attributes:

12
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,12 @@
<!--
IMPORTANT:
1. Create an issue and wait for a maintainer to approve it BEFORE opening a pull request.
2. Don't open a work-in-progress pull request. If you open a PR, the PR must be ready to be reviewed.
3. If a pull request doesn't follow one of the previous elements, it will be closed.
Also, pull requests from a fork inside a GitHub organization are not allowed because of access limitation on them.
Only pull requests from personal forks are allowed.
-->

View file

@ -12,20 +12,16 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
HUGO_VERSION: 0.131.0
HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
steps:
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}

View file

@ -20,13 +20,8 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
# https://github.com/marketplace/actions/checkout
- name: Checkout code
uses: actions/checkout@v4
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}

View file

@ -13,54 +13,44 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
GOLANGCI_LINT_VERSION: v1.62.0
HUGO_VERSION: 0.131.0
GOLANGCI_LINT_VERSION: v2.10
HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
LEGO_E2E_TESTS: CI
MEMCACHED_HOSTS: localhost:11211
steps:
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Check and get dependencies
run: |
go mod tidy
git diff --exit-code go.mod
git diff --exit-code go.sum
go mod tidy --diff
# https://golangci-lint.run/usage/install#other-ci
- name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
- name: Generate and Check generated elements
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
golangci-lint --version
make generate-dns
git diff --exit-code
- uses: golangci/golangci-lint-action@v9
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}
install-only: true
- name: Install Pebble
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@3fe019bbc0a41ed16e2fee31592bb91751acaa47
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0
- name: Install challtestsrv
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@3fe019bbc0a41ed16e2fee31592bb91751acaa47
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0
- name: Set up a Memcached server
uses: niden/actions-memcached@v7
- name: Setup /etc/hosts
run: |
echo "127.0.0.1 acme.wtf" | sudo tee -a /etc/hosts
echo "127.0.0.1 lego.wtf" | sudo tee -a /etc/hosts
echo "127.0.0.1 acme.lego.wtf" | sudo tee -a /etc/hosts
echo "127.0.0.1 légô.wtf" | sudo tee -a /etc/hosts
echo "127.0.0.1 xn--lg-bja9b.wtf" | sudo tee -a /etc/hosts
run: docker run -d --rm -p 11211:11211 memcached:1.6-alpine
- name: Make
run: |

View file

@ -5,6 +5,11 @@ on:
tags:
- v*
permissions:
# Allow the workflow to write attestations.
id-token: write
attestations: write
jobs:
release:
@ -37,13 +42,11 @@ jobs:
docker-images: true
swap-storage: false
- name: Check out code
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
@ -64,10 +67,21 @@ jobs:
# https://goreleaser.com/ci/actions/
- name: Run GoReleaser
id: goreleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
version: v2.13.0
args: release -p 1 --clean --timeout=90m
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }}
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
AUR_KEY: ${{ secrets.AUR_KEY }}
- uses: actions/attest-build-provenance@v3
with:
subject-checksums: ./dist/lego_${{ fromJSON(steps.goreleaser.outputs.metadata).version }}_checksums.txt
github-token: ${{ secrets.GH_TOKEN_REPO }}
- uses: actions/attest-build-provenance@v3
with:
subject-checksums: ./dist/digests.txt
github-token: ${{ secrets.GH_TOKEN_REPO }}

View file

@ -1,265 +1,284 @@
version: "2"
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
settings:
gofumpt:
extra-rules: true
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
linters:
enable-all: true
default: all
disable:
- wsl # Deprecated
- bodyclose
- canonicalheader
- contextcheck
- cyclop # duplicate of gocyclo
- sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL)
- lll
- gosec
- dupl # not relevant
- prealloc # too many false-positive
- bodyclose # too many false-positive
- mnd
- testpackage # not relevant
- tparallel # not relevant
- paralleltest # not relevant
- nestif # too many false-positive
- wrapcheck
- err113 # not relevant
- nlreturn # not relevant
- wsl # not relevant
- errchkjson
- errname
- exhaustive # not relevant
- exhaustruct # not relevant
- makezero # not relevant
- forbidigo
- varnamelen # not relevant
- nilnil # not relevant
- ireturn # not relevant
- contextcheck # too many false-positive
- tenv # we already have a test "framework" to handle env vars
- noctx
- forcetypeassert
- tagliatelle
- errname
- errchkjson
- nonamedreturns
- gosec
- gosmopolitan # not relevant
- ireturn # not relevant
- lll
- makezero # not relevant
- mnd
- musttag # false-positive https://github.com/junk1tm/musttag/issues/17
- gosmopolitan # not relevant
- exportloopref # Useless with go1.22
- canonicalheader # Can create side effects in the context of API clients
- usestdlibvars # false-positive https://github.com/sashamelentyev/usestdlibvars/issues/96
- nestif # too many false-positive
- nilnil # not relevant
- nlreturn # not relevant
- noctx
- noinlineerr # too strict
- nonamedreturns
- paralleltest # not relevant
- prealloc # too many false-positive
- rowserrcheck # not relevant (SQL)
- sqlclosecheck # not relevant (SQL)
- tagliatelle
- testpackage # not relevant
- tparallel # not relevant
- varnamelen # not relevant
- wrapcheck
linters-settings:
govet:
enable:
- shadow
gocyclo:
min-complexity: 12
goconst:
min-len: 3
min-occurrences: 3
funlen:
lines: -1
statements: 50
misspell:
locale: US
ignore-words:
- internetbs
depguard:
settings:
depguard:
rules:
main:
deny:
- pkg: github.com/instana/testify
desc: not allowed
- pkg: github.com/pkg/errors
desc: Should be replaced by standard lib errors package
funlen:
lines: -1
statements: 50
goconst:
min-len: 3
min-occurrences: 3
gocritic:
disabled-checks:
- paramTypeCombine # already handle by gofumpt.extra-rules
- whyNoLint # already handle by nonolint
- unnamedResult
- hugeParam
- sloppyReassign
- rangeValCopy
- octalLiteral
- ptrToRefParam
- appendAssign
- ruleguard
- httpNoBody
- exposedSyncMutex
enabled-tags:
- diagnostic
- style
- performance
gocyclo:
min-complexity: 12
godox:
keywords:
- FIXME
govet:
disable:
- fieldalignment
enable-all: true
settings:
printf:
funcs:
- Print
- Printf
- Warn
- Warnf
- Fatal
- Fatalf
misspell:
locale: US
ignore-rules:
- internetbs
perfsprint:
err-error: true
errorf: true
sprintf1: true
strconcat: false
revive:
rules:
- name: struct-tag
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
tagalign:
align: false
order:
- xml
- json
- yaml
- yml
- toml
- mapstructure
- url
testifylint:
disable:
- require-error
- go-require
usetesting:
os-setenv: false # we already have a test "framework" to handle env vars
funcorder:
struct-method: false
exclusions:
warn-unused: true
presets:
- comments
- std-error-handling
paths:
# Those elements are related to code borrowed from the official HuaweiCloud API client.
- providers/dns/huaweicloud/internal
rules:
main:
deny:
- pkg: "github.com/instana/testify"
desc: not allowed
- pkg: "github.com/pkg/errors"
desc: Should be replaced by standard lib errors package
tagalign:
align: false
order:
- xml
- json
- yaml
- yml
- toml
- mapstructure
- url
godox:
keywords:
- FIXME
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- paramTypeCombine # already handle by gofumpt.extra-rules
- whyNoLint # already handle by nonolint
- unnamedResult
- hugeParam
- sloppyReassign
- rangeValCopy
- octalLiteral
- ptrToRefParam
- appendAssign
- ruleguard
- httpNoBody
- exposedSyncMutex
revive:
rules:
- name: struct-tag
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
testifylint:
disable:
- require-error
- go-require
perfsprint:
err-error: true
errorf: true
sprintf1: true
strconcat: false
run:
timeout: 10m
output:
show-stats: true
sort-results: true
sort-order:
- linter
- file
- path: (.+)_test.go
linters:
- funlen
- goconst
- maintidx
- path: (.+)_test.go
text: Error return value of `fmt.Fprintln` is not checked
linters:
- errcheck
- text: "var-naming: avoid meaningless package names"
linters:
- revive
- text: "var-naming: avoid package names that conflict with Go standard library package names"
linters:
- revive
- path: certcrypto/crypto.go
text: (tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable
linters:
- gochecknoglobals
- path: challenge/dns01/nameserver.go
text: (defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable
linters:
- gochecknoglobals
- path: challenge/dns01/nameserver_.+.go
text: dnsTimeout is a global variable
linters:
- gochecknoglobals
- path: challenge/dns01/precheck.go
text: defaultNameserverPort is a global variable
linters:
- gochecknoglobals
- path: challenge/http01/domain_matcher.go
text: cyclomatic complexity \d+ of func `parseForwardedHeader` is high
linters:
- gocyclo
- path: challenge/http01/domain_matcher.go
text: Function 'parseForwardedHeader' has too many statements
linters:
- funlen
- path: challenge/tlsalpn01/tls_alpn_challenge.go
text: idPeAcmeIdentifierV1 is a global variable
linters:
- gochecknoglobals
- path: log/logger.go
text: Logger is a global variable
linters:
- gochecknoglobals
- path: e2e/(dnschallenge/)?[\d\w]+_test.go
text: load is a global variable
linters:
- gochecknoglobals
- path: providers/(dns|http)/([\d\w]+/)*[\d\w]+_test.go
text: envTest is a global variable
linters:
- gochecknoglobals
- path: providers/dns/namecheap/namecheap_test.go
text: testCases is a global variable
linters:
- gochecknoglobals
- path: providers/dns/namecheap/transport.go
text: (envProxyOnce|envProxyFuncValue) is a global variable
linters:
- gochecknoglobals
- path: providers/dns/acmedns/mock_test.go
text: egTestAccount is a global variable
linters:
- gochecknoglobals
- path: providers/http/memcached/memcached_test.go
text: memcachedHosts is a global variable
linters:
- gochecknoglobals
- path: providers/dns/checkdomain/internal/types.go
text: '`payed` is a misspelling of `paid`'
linters:
- misspell
- path: platform/tester/env_test.go
linters:
- thelper
- path: providers/dns/oraclecloud/oraclecloud_test.go
text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16'
linters:
- staticcheck
- path: providers/dns/sakuracloud/wrapper.go
text: mu is a global variable
linters:
- gochecknoglobals
- path: cmd/cmd_renew.go
text: cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high
linters:
- gocyclo
- path: cmd/cmd_renew.go
text: Function 'renewForDomains' has too many statements
linters:
- funlen
- path: providers/dns/cpanel/cpanel.go
text: cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high
linters:
- gocyclo
- path: providers/dns/manual/manual.go
text: 'SA1019: dns01.DNSProviderManual is deprecated'
linters:
- staticcheck
# Those elements have been replaced by non-exposed structures.
- path: providers/dns/linode/linode_test.go
text: 'SA1019: linodego\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated'
linters:
- staticcheck
issues:
exclude-generated: strict
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
exclude:
- 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
- 'exported (type|method|function) (.+) should have comment or be unexported'
- 'ST1000: at least one file in a package should have a package comment'
exclude-rules:
- path: (.+)_test.go
linters:
- funlen
- goconst
- maintidx
- path: (.+)_test.go
text: 'Error return value of `fmt.Fprintln` is not checked'
linters:
- errcheck
- path: providers/dns/dns_providers.go
linters:
- gocyclo
- path: certcrypto/crypto.go
text: '(tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable'
linters:
- gochecknoglobals
- path: challenge/dns01/nameserver.go
text: '(defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable'
linters:
- gochecknoglobals
- path: challenge/dns01/nameserver_.+.go
text: 'dnsTimeout is a global variable'
linters:
- gochecknoglobals
- path: challenge/dns01/nameserver_test.go
text: 'findXByFqdnTestCases is a global variable'
linters:
- gochecknoglobals
- path: challenge/http01/domain_matcher.go
text: 'string `Host` has \d occurrences, make it a constant'
linters:
- goconst
- path: challenge/http01/domain_matcher.go
text: 'cyclomatic complexity \d+ of func `parseForwardedHeader` is high'
linters:
- gocyclo
- path: challenge/http01/domain_matcher.go
text: "Function 'parseForwardedHeader' has too many statements"
linters:
- funlen
- path: challenge/tlsalpn01/tls_alpn_challenge.go
text: 'idPeAcmeIdentifierV1 is a global variable'
linters:
- gochecknoglobals
- path: log/logger.go
text: 'Logger is a global variable'
linters:
- gochecknoglobals
- path: 'e2e/(dnschallenge/)?[\d\w]+_test.go'
text: load is a global variable
linters:
- gochecknoglobals
- path: 'providers/dns/([\d\w]+/)*[\d\w]+_test.go'
text: 'envTest is a global variable'
linters:
- gochecknoglobals
- path: 'providers/http/([\d\w]+/)*[\d\w]+_test.go'
text: 'envTest is a global variable'
linters:
- gochecknoglobals
- path: providers/dns/namecheap/namecheap_test.go
text: 'testCases is a global variable'
linters:
- gochecknoglobals
- path: providers/dns/acmedns/acmedns_test.go
text: 'egTestAccount is a global variable'
linters:
- gochecknoglobals
- path: providers/http/memcached/memcached_test.go
text: 'memcachedHosts is a global variable'
linters:
- gochecknoglobals
- path: cmd/zz_gen_cmd_dnshelp.go
linters:
- gocyclo
- funlen
- path: providers/dns/checkdomain/internal/types.go
text: '`payed` is a misspelling of `paid`'
linters:
- misspell
- path: platform/tester/env_test.go
linters:
- thelper
- path: providers/dns/oraclecloud/oraclecloud_test.go
text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16'
linters:
- staticcheck
- path: providers/dns/sakuracloud/wrapper.go
text: 'mu is a global variable'
linters:
- gochecknoglobals
- path: cmd/cmd_renew.go
text: 'cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high'
linters:
- gocyclo
- path: providers/dns/cpanel/cpanel.go
text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high'
linters:
- gocyclo
- path: providers/dns/servercow/internal/types.go
text: 'the methods of "Value" use pointer receiver and non-pointer receiver.'
linters:
- recvcheck
# Those elements have been replaced by non-exposed structures.
- path: providers/dns/linode/linode_test.go
linters:
- staticcheck
text: "SA1019: linodego\\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated"

View file

@ -42,6 +42,10 @@ builds:
goarch: 386
- goos: openbsd
goarch: arm
# Deprecated in go1.25, Removed in go1.26
# https://go.dev/doc/go1.25#windows
- goos: windows
goarch: arm
changelog:
sort: asc
@ -51,125 +55,98 @@ changelog:
- '(?i)^Detach v[\d|.]+'
- '(?i)^Prepare release v[\d|.]+'
release:
skip_upload: false
github:
owner: 'go-acme'
name: 'lego'
header: |
lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
Everybody thinks that the others will donate, but in the end, nobody does.
So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
For key updates, see the [changelog](https://github.com/go-acme/lego/blob/HEAD/CHANGELOG.md#v{{ .Major }}{{ .Minor }}{{ .Patch }}).
archives:
- id: lego
name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}'
format: tar.gz
formats: ['tar.gz']
format_overrides:
- goos: windows
format: zip
formats: ['zip']
files:
- LICENSE
- CHANGELOG.md
docker_manifests:
- name_template: 'goacme/lego:{{ .Tag }}'
image_templates:
- 'goacme/lego:{{ .Tag }}-amd64'
- 'goacme/lego:{{ .Tag }}-arm64'
- 'goacme/lego:{{ .Tag }}-armv7'
- name_template: 'goacme/lego:latest'
image_templates:
- 'goacme/lego:{{ .Tag }}-amd64'
- 'goacme/lego:{{ .Tag }}-arm64'
- 'goacme/lego:{{ .Tag }}-armv7'
- name_template: 'goacme/lego:v{{ .Major }}.{{ .Minor }}'
image_templates:
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7'
dockers:
- use: buildx
goos: linux
goarch: amd64
dockers_v2:
- images:
- 'goacme/lego'
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-amd64'
- 'goacme/lego:{{ .Tag }}-amd64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64'
build_flag_templates:
- '--pull'
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
tags:
- 'latest'
- 'v{{ .Major }}'
- 'v{{ .Major }}.{{ .Minor }}'
- '{{ .Tag }}'
labels:
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- '--label=org.opencontainers.image.title={{.ProjectName}}'
- '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- '--label=org.opencontainers.image.source={{.GitURL}}'
- '--label=org.opencontainers.image.url={{.GitURL}}'
- '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- '--label=org.opencontainers.image.created={{.Date}}'
- '--label=org.opencontainers.image.revision={{.FullCommit}}'
- '--label=org.opencontainers.image.version={{.Version}}'
- '--platform=linux/amd64'
'org.opencontainers.image.title': '{{.ProjectName}}'
'org.opencontainers.image.description': 'Lets Encrypt/ACME client and library written in Go'
'org.opencontainers.image.source': '{{.GitURL}}'
'org.opencontainers.image.url': '{{.GitURL}}'
'org.opencontainers.image.documentation': 'https://go-acme.github.io/lego'
'org.opencontainers.image.created': '{{.Date}}'
'org.opencontainers.image.revision': '{{.FullCommit}}'
'org.opencontainers.image.version': '{{.Version}}'
- use: buildx
goos: linux
goarch: arm64
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-arm64'
- 'goacme/lego:{{ .Tag }}-arm64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64'
build_flag_templates:
- '--pull'
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- '--label=org.opencontainers.image.title={{.ProjectName}}'
- '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- '--label=org.opencontainers.image.source={{.GitURL}}'
- '--label=org.opencontainers.image.url={{.GitURL}}'
- '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- '--label=org.opencontainers.image.created={{.Date}}'
- '--label=org.opencontainers.image.revision={{.FullCommit}}'
- '--label=org.opencontainers.image.version={{.Version}}'
- '--platform=linux/arm64'
- use: buildx
goos: linux
goarch: arm
goarm: '7'
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-armv7'
- 'goacme/lego:{{ .Tag }}-armv7'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7'
build_flag_templates:
- '--pull'
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- '--label=org.opencontainers.image.title={{.ProjectName}}'
- '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- '--label=org.opencontainers.image.source={{.GitURL}}'
- '--label=org.opencontainers.image.url={{.GitURL}}'
- '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- '--label=org.opencontainers.image.created={{.Date}}'
- '--label=org.opencontainers.image.revision={{.FullCommit}}'
- '--label=org.opencontainers.image.version={{.Version}}'
- '--platform=linux/arm/v7'
# Disabled because https://github.com/go-acme/lego/pull/2134#issuecomment-2135293270
snapcrafts:
- name: lego
disable: true
- name_template: "{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
disable: false
publish: true
grade: stable
confinement: strict
license: MIT
base: core22
publish: true
summary: Lego is a Let's Encrypt/ACME client.
description: |
Lego is a Let's Encrypt/ACME client written in Go.
The lego snap makes it easy to install and use Lego on any Linux distribution that supports snaps.
Usage:
* `sudo snap install lego`
* `sudo lego --email="you@example.com" --domains="example.com" --server=https://acme-staging-v02.api.letsencrypt.org/directory --http --http.port :8080 run
channel_templates:
- edge
apps:
lego:
command: bin/lego
command: lego
environment:
LEGO_PATH: /var/snap/lego/common/.lego
plugs:
- network-bind
aurs:
- description: "Let s Encrypt client and ACME library written in Go"
skip_upload: false
homepage: https://go-acme.github.io/lego/
name: 'lego-bin'
provides:
- lego
maintainers:
- "Fernandez Ludovic <lfernandez dot dev at gmail dot com>"
license: APACHE
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/lego-bin.git"
commit_author:
name: ldez
email: ldez@users.noreply.github.com
package: |-
# Bin
install -Dm755 "./lego" "${pkgdir}/usr/bin/lego"
# License
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/lego/LICENSE"

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ To ensure a great and easy experience for everyone, please review the few guidel
- If both of the above do not apply, create a new issue and include as much information as possible.
Bug reports should include all information a person could need to reproduce your problem without the need to
follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behaviour and the actual behaviour.
follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behavior and the actual behavior.
## Feature proposals and requests
@ -20,31 +20,26 @@ It is up to you to make a strong point about your proposal and convince us of th
## Pull requests
Create an issue and wait for a maintainer to approve it BEFORE opening a pull request.
Patches, new features and improvements are a great way to help the project.
Please keep them focused on one thing and do not include unrelated commits.
All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests.
All pull requests that alter the behavior of the program,
add new behavior or somehow alter code in a non-trivial way should **always** include tests.
If you want to contribute a significant pull request (with a non-trivial workload for you) please **ask first**. We do not want you to spend
a lot of time on something the project's developers might not want to merge into the project.
**IMPORTANT**: By submitting a patch, you agree to allow the project
owners to license your work under the terms of the [MIT License](LICENSE).
**IMPORTANT**: By submitting a patch, you agree to allow the project owners to license your work under the terms of the [MIT License](LICENSE).
### How to create a pull request
Requirements:
- `go` v1.15+
- `go` v1.24+
- environment variable: `GO111MODULE=on`
First, you have to install [GoLang](https://golang.org/doc/install) and [golangci-lint](https://github.com/golangci/golangci-lint#install).
```bash
# Create the root folder
mkdir -p $GOPATH/src/github.com/go-acme
cd $GOPATH/src/github.com/go-acme
# clone your fork
git clone git@github.com:YOUR_USERNAME/lego.git
cd lego
@ -56,14 +51,12 @@ git fetch upstream
```bash
# Create your branch
git checkout -b my-feature
git switch -c my-feature
## Create your code ##
```
```bash
# Format
make fmt
# Linters
make checks
# Tests

View file

@ -54,10 +54,10 @@ detach:
.PHONY: docs-build docs-serve docs-themes
docs-build: generate-dns
@make -C ./docs hugo-build
@make -C ./docs build
docs-serve: generate-dns
@make -C ./docs hugo
@make -C ./docs serve
docs-themes:
@make -C ./docs hugo-themes

135
README.md
View file

@ -5,29 +5,36 @@
# Lego
Let's Encrypt client and ACME library written in Go.
[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go.
[![Go Reference](https://pkg.go.dev/badge/github.com/go-acme/lego/v4.svg)](https://pkg.go.dev/github.com/go-acme/lego/v4)
[![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/goacme/lego.svg)](https://hub.docker.com/r/goacme/lego/)
lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
Everybody thinks that the others will donate, but in the end, nobody does.
So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
## Features
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS ApplicationLayer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
- Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension
- Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension
- Comes with about [180 DNS providers](https://go-acme.github.io/lego/dns)
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
- Revoke certificates
- Robust implementation of all ACME challenges
- Robust implementation of ACME challenges:
- HTTP (http-01)
- DNS (dns-01)
- TLS (tls-alpn-01)
- SAN certificate support
- [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default
- Comes with multiple optional [DNS providers](https://go-acme.github.io/lego/dns)
- [Custom challenge solvers](https://go-acme.github.io/lego/usage/library/writing-a-challenge-solver/)
- Certificate bundling
- OCSP helper function
@ -49,77 +56,114 @@ Documentation is hosted live at https://go-acme.github.io/lego/.
Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml).
<!-- START DNS PROVIDERS LIST -->
<table><tr>
<td><a href="https://go-acme.github.io/lego/dns/com35/">35.com/三五互联</a></td>
<td><a href="https://go-acme.github.io/lego/dns/active24/">Active24</a></td>
<td><a href="https://go-acme.github.io/lego/dns/edgedns/">Akamai EdgeDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/alidns/">Alibaba Cloud DNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/aliesa/">AlibabaCloud ESA</a></td>
<td><a href="https://go-acme.github.io/lego/dns/allinkl/">all-inkl</a></td>
<td><a href="https://go-acme.github.io/lego/dns/alwaysdata/">Alwaysdata</a></td>
<td><a href="https://go-acme.github.io/lego/dns/lightsail/">Amazon Lightsail</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/route53/">Amazon Route 53</a></td>
<td><a href="https://go-acme.github.io/lego/dns/anexia/">Anexia CloudDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/safedns/">ANS SafeDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/artfiles/">ArtFiles</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/arvancloud/">ArvanCloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/auroradns/">Aurora DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/autodns/">Autodns</a></td>
<td><a href="https://go-acme.github.io/lego/dns/axelname/">Axelname</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/azion/">Azion</a></td>
<td><a href="https://go-acme.github.io/lego/dns/azure/">Azure (deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/azuredns/">Azure DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/baiducloud/">Baidu Cloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/beget/">Beget.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/binarylane/">Binary Lane</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bindman/">Bindman</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bluecat/">Bluecat</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/bluecatv2/">Bluecat v2</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bookmyname/">BookMyName</a></td>
<td><a href="https://go-acme.github.io/lego/dns/brandit/">Brandit (deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bunny/">Bunny</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/checkdomain/">Checkdomain</a></td>
<td><a href="https://go-acme.github.io/lego/dns/civo/">Civo</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cloudru/">Cloud.ru</a></td>
<td><a href="https://go-acme.github.io/lego/dns/clouddns/">CloudDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cloudflare/">Cloudflare</a></td>
<td><a href="https://go-acme.github.io/lego/dns/cloudns/">ClouDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cloudxns/">CloudXNS (Deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/conoha/">ConoHa</a></td>
<td><a href="https://go-acme.github.io/lego/dns/conoha/">ConoHa v2</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/conohav3/">ConoHa v3</a></td>
<td><a href="https://go-acme.github.io/lego/dns/constellix/">Constellix</a></td>
<td><a href="https://go-acme.github.io/lego/dns/corenetworks/">Core-Networks</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cpanel/">CPanel/WHM</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/czechia/">Czechia</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ddnss/">DDnss (DynDNS Service)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/derak/">Derak Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/desec/">deSEC.io</a></td>
<td><a href="https://go-acme.github.io/lego/dns/designate/">Designate DNSaaS for Openstack</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/designate/">Designate DNSaaS for Openstack</a></td>
<td><a href="https://go-acme.github.io/lego/dns/digitalocean/">Digital Ocean</a></td>
<td><a href="https://go-acme.github.io/lego/dns/directadmin/">DirectAdmin</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnsmadeeasy/">DNS Made Easy</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnshomede/">dnsHome.de</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dnsexit/">DNSExit</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnshomede/">dnsHome.de</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnsimple/">DNSimple</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnspod/">DNSPod (deprecated)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dode/">Domain Offensive (do.de)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/domeneshop/">Domeneshop</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dreamhost/">DreamHost</a></td>
<td><a href="https://go-acme.github.io/lego/dns/duckdns/">Duck DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dyn/">Dyn</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dynu/">Dynu</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dyn/">Dyn</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dyndnsfree/">DynDnsFree.de</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dynu/">Dynu</a></td>
<td><a href="https://go-acme.github.io/lego/dns/easydns/">EasyDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/edgecenter/">EdgeCenter</a></td>
<td><a href="https://go-acme.github.io/lego/dns/efficientip/">Efficient IP</a></td>
<td><a href="https://go-acme.github.io/lego/dns/epik/">Epik</a></td>
<td><a href="https://go-acme.github.io/lego/dns/exoscale/">Exoscale</a></td>
<td><a href="https://go-acme.github.io/lego/dns/eurodns/">EuroDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/excedo/">Excedo</a></td>
<td><a href="https://go-acme.github.io/lego/dns/exoscale/">Exoscale</a></td>
<td><a href="https://go-acme.github.io/lego/dns/exec/">External program</a></td>
<td><a href="https://go-acme.github.io/lego/dns/f5xc/">F5 XC</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/freemyip/">freemyip.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/namesurfer/">FusionLayer NameSurfer</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gcore/">G-Core</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gandi/">Gandi</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/gandiv5/">Gandi Live DNS (v5)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gigahostno/">Gigahost.no</a></td>
<td><a href="https://go-acme.github.io/lego/dns/glesys/">Glesys</a></td>
<td><a href="https://go-acme.github.io/lego/dns/godaddy/">Go Daddy</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gcloud/">Google Cloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/gcloud/">Google Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/googledomains/">Google Domains</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gravity/">Gravity</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hetzner/">Hetzner</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hostingde/">Hosting.de</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hostingnl/">Hosting.nl</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hostinger/">Hostinger</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hosttech/">Hosttech</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/httpreq/">HTTP request</a></td>
@ -138,49 +182,64 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
<td><a href="https://go-acme.github.io/lego/dns/inwx/">INWX</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ionos/">Ionos</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ionoscloud/">Ionos Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ipv64/">IPv64</a></td>
<td><a href="https://go-acme.github.io/lego/dns/iwantmyname/">iwantmyname</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ispconfig/">ISPConfig 3</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ispconfigddns/">ISPConfig 3 - Dynamic DNS (DDNS) Module</a></td>
<td><a href="https://go-acme.github.io/lego/dns/iwantmyname/">iwantmyname (Deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/jdcloud/">JD Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/joker/">Joker</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/acme-dns/">Joohoi&#39;s ACME-DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/keyhelp/">KeyHelp</a></td>
<td><a href="https://go-acme.github.io/lego/dns/leaseweb/">Leaseweb</a></td>
<td><a href="https://go-acme.github.io/lego/dns/liara/">Liara</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/limacity/">Lima-City</a></td>
<td><a href="https://go-acme.github.io/lego/dns/linode/">Linode (v4)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/liquidweb/">Liquid Web</a></td>
<td><a href="https://go-acme.github.io/lego/dns/loopia/">Loopia</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/luadns/">LuaDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/mailinabox/">Mail-in-a-Box</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/manageengine/">ManageEngine CloudDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/manual/">Manual</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/metaname/">Metaname</a></td>
<td><a href="https://go-acme.github.io/lego/dns/metaregistrar/">Metaregistrar</a></td>
<td><a href="https://go-acme.github.io/lego/dns/mijnhost/">mijn.host</a></td>
<td><a href="https://go-acme.github.io/lego/dns/mittwald/">Mittwald</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/myaddr/">myaddr.{tools,dev,io}</a></td>
<td><a href="https://go-acme.github.io/lego/dns/mydnsjp/">MyDNS.jp</a></td>
<td><a href="https://go-acme.github.io/lego/dns/mythicbeasts/">MythicBeasts</a></td>
<td><a href="https://go-acme.github.io/lego/dns/namedotcom/">Name.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/namecheap/">Namecheap</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/namecheap/">Namecheap</a></td>
<td><a href="https://go-acme.github.io/lego/dns/namesilo/">Namesilo</a></td>
<td><a href="https://go-acme.github.io/lego/dns/nearlyfreespeech/">NearlyFreeSpeech.NET</a></td>
<td><a href="https://go-acme.github.io/lego/dns/neodigit/">Neodigit</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/netcup/">Netcup</a></td>
<td><a href="https://go-acme.github.io/lego/dns/netlify/">Netlify</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/nicmanager/">Nicmanager</a></td>
<td><a href="https://go-acme.github.io/lego/dns/nifcloud/">NIFCloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/njalla/">Njalla</a></td>
<td><a href="https://go-acme.github.io/lego/dns/nodion/">Nodion</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ns1/">NS1</a></td>
<td><a href="https://go-acme.github.io/lego/dns/octenium/">Octenium</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/otc/">Open Telekom Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/oraclecloud/">Oracle Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ovh/">OVH</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/plesk/">plesk.com</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/porkbun/">Porkbun</a></td>
<td><a href="https://go-acme.github.io/lego/dns/pdns/">PowerDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/rackspace/">Rackspace</a></td>
<td><a href="https://go-acme.github.io/lego/dns/rainyun/">Rain Yun/雨云</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/rcodezero/">RcodeZero</a></td>
<td><a href="https://go-acme.github.io/lego/dns/regru/">reg.ru</a></td>
@ -188,51 +247,61 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
<td><a href="https://go-acme.github.io/lego/dns/rfc2136/">RFC2136</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/rimuhosting/">RimuHosting</a></td>
<td><a href="https://go-acme.github.io/lego/dns/nicru/">RU CENTER</a></td>
<td><a href="https://go-acme.github.io/lego/dns/sakuracloud/">Sakura Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/scaleway/">Scaleway</a></td>
<td><a href="https://go-acme.github.io/lego/dns/selectel/">Selectel</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/selectel/">Selectel</a></td>
<td><a href="https://go-acme.github.io/lego/dns/selectelv2/">Selectel v2</a></td>
<td><a href="https://go-acme.github.io/lego/dns/selfhostde/">SelfHost.(de|eu)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/servercow/">Servercow</a></td>
<td><a href="https://go-acme.github.io/lego/dns/shellrent/">Shellrent</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/shellrent/">Shellrent</a></td>
<td><a href="https://go-acme.github.io/lego/dns/simply/">Simply.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/sonic/">Sonic</a></td>
<td><a href="https://go-acme.github.io/lego/dns/stackpath/">Stackpath</a></td>
<td><a href="https://go-acme.github.io/lego/dns/technitium/">Technitium</a></td>
<td><a href="https://go-acme.github.io/lego/dns/spaceship/">Spaceship</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/stackpath/">Stackpath</a></td>
<td><a href="https://go-acme.github.io/lego/dns/syse/">Syse</a></td>
<td><a href="https://go-acme.github.io/lego/dns/technitium/">Technitium</a></td>
<td><a href="https://go-acme.github.io/lego/dns/tencentcloud/">Tencent Cloud DNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/edgeone/">Tencent EdgeOne</a></td>
<td><a href="https://go-acme.github.io/lego/dns/timewebcloud/">Timeweb Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/todaynic/">TodayNIC/时代互联</a></td>
<td><a href="https://go-acme.github.io/lego/dns/transip/">TransIP</a></td>
<td><a href="https://go-acme.github.io/lego/dns/safedns/">UKFast SafeDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ultradns/">Ultradns</a></td>
<td><a href="https://go-acme.github.io/lego/dns/uniteddomains/">United-Domains</a></td>
<td><a href="https://go-acme.github.io/lego/dns/variomedia/">Variomedia</a></td>
<td><a href="https://go-acme.github.io/lego/dns/vegadns/">VegaDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/vercel/">Vercel</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/vercel/">Vercel</a></td>
<td><a href="https://go-acme.github.io/lego/dns/versio/">Versio.[nl|eu|uk]</a></td>
<td><a href="https://go-acme.github.io/lego/dns/vinyldns/">VinylDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/virtualname/">Virtualname</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/vkcloud/">VK Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/volcengine/">Volcano Engine/火山引擎</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/vscale/">Vscale</a></td>
<td><a href="https://go-acme.github.io/lego/dns/vultr/">Vultr</a></td>
<td><a href="https://go-acme.github.io/lego/dns/webnames/">Webnames</a></td>
<td><a href="https://go-acme.github.io/lego/dns/websupport/">Websupport</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/webnamesca/">webnames.ca</a></td>
<td><a href="https://go-acme.github.io/lego/dns/webnames/">webnames.ru</a></td>
<td><a href="https://go-acme.github.io/lego/dns/websupport/">Websupport</a></td>
<td><a href="https://go-acme.github.io/lego/dns/wedos/">WEDOS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/westcn/">West.cn/西部数码</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandex360/">Yandex 360</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandexcloud/">Yandex Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandex/">Yandex PDD</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/zoneee/">Zone.ee</a></td>
<td><a href="https://go-acme.github.io/lego/dns/zoneedit/">ZoneEdit</a></td>
<td><a href="https://go-acme.github.io/lego/dns/zonomi/">Zonomi</a></td>
<td></td>
<td></td>
</tr></table>
<!-- END DNS PROVIDERS LIST -->
If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.md).
If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml).

View file

@ -13,6 +13,7 @@ type AccountService service
// New Creates a new account.
func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) {
var account acme.Account
resp, err := a.core.post(a.core.GetDirectory().NewAccountURL, req, &account)
location := getLocation(resp)
@ -29,9 +30,9 @@ func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) {
// NewEAB Creates a new account with an External Account Binding.
func (a *AccountService) NewEAB(accMsg acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) {
hmac, err := base64.RawURLEncoding.DecodeString(hmacEncoded)
hmac, err := decodeEABHmac(hmacEncoded)
if err != nil {
return acme.ExtendedAccount{}, fmt.Errorf("acme: could not decode hmac key: %w", err)
return acme.ExtendedAccount{}, err
}
eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac)
@ -51,10 +52,12 @@ func (a *AccountService) Get(accountURL string) (acme.Account, error) {
}
var account acme.Account
_, err := a.core.postAsGet(accountURL, &account)
if err != nil {
return acme.Account{}, err
}
return account, nil
}
@ -65,6 +68,7 @@ func (a *AccountService) Update(accountURL string, req acme.Account) (acme.Accou
}
var account acme.Account
_, err := a.core.post(accountURL, req, &account)
if err != nil {
return acme.Account{}, err
@ -81,5 +85,20 @@ func (a *AccountService) Deactivate(accountURL string) error {
req := acme.Account{Status: acme.StatusDeactivated}
_, err := a.core.post(accountURL, req, nil)
return err
}
func decodeEABHmac(hmacEncoded string) ([]byte, error) {
hmac, errRaw := base64.RawURLEncoding.DecodeString(hmacEncoded)
if errRaw == nil {
return hmac, nil
}
hmac, err := base64.URLEncoding.DecodeString(hmacEncoded)
if err == nil {
return hmac, nil
}
return nil, fmt.Errorf("acme: could not decode hmac key: %w", errors.Join(errRaw, err))
}

35
acme/api/account_test.go Normal file
View file

@ -0,0 +1,35 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_decodeEABHmac(t *testing.T) {
testCases := []struct {
desc string
hmac string
}{
{
desc: "RawURLEncoding",
hmac: "BAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHx",
},
{
desc: "URLEncoding",
hmac: "nKTo9Hu8fpCqWPXx-25LVbZrJWxcHISsr4qHrRR0j5U=",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
v, err := decodeEABHmac(test.hmac)
require.NoError(t, err)
assert.NotEmpty(t, v)
})
}
}

View file

@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"crypto"
"encoding/json"
"errors"
@ -9,7 +10,7 @@ import (
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/nonces"
"github.com/go-acme/lego/v4/acme/api/internal/secure"
@ -60,7 +61,7 @@ func New(httpClient *http.Client, userAgent, caDirURL, kid string, privateKey cr
// post performs an HTTP POST request and parses the response body as JSON,
// into the provided respBody object.
func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response, error) {
func (a *Core) post(uri string, reqBody, response any) (*http.Response, error) {
content, err := json.Marshal(reqBody)
if err != nil {
return nil, errors.New("failed to marshal message")
@ -71,47 +72,44 @@ func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response,
// postAsGet performs an HTTP POST ("POST-as-GET") request.
// https://www.rfc-editor.org/rfc/rfc8555.html#section-6.3
func (a *Core) postAsGet(uri string, response interface{}) (*http.Response, error) {
func (a *Core) postAsGet(uri string, response any) (*http.Response, error) {
return a.retrievablePost(uri, []byte{}, response)
}
func (a *Core) retrievablePost(uri string, content []byte, response interface{}) (*http.Response, error) {
func (a *Core) retrievablePost(uri string, content []byte, response any) (*http.Response, error) {
ctx := context.Background()
// during tests, allow to support ~90% of bad nonce with a minimum of attempts.
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 200 * time.Millisecond
bo.MaxInterval = 5 * time.Second
bo.MaxElapsedTime = 20 * time.Second
var resp *http.Response
operation := func() error {
var err error
resp, err = a.signedPost(uri, content, response)
operation := func() (*http.Response, error) {
resp, err := a.signedPost(uri, content, response)
if err != nil {
// Retry if the nonce was invalidated
var e *acme.NonceError
if errors.As(err, &e) {
return err
return resp, err
}
return backoff.Permanent(err)
return resp, backoff.Permanent(err)
}
return nil
return resp, nil
}
notify := func(err error, duration time.Duration) {
log.Infof("retry due to: %v", err)
}
err := backoff.RetryNotify(operation, bo, notify)
if err != nil {
return resp, err
}
return resp, nil
return backoff.Retry(ctx, operation,
backoff.WithBackOff(bo),
backoff.WithMaxElapsedTime(20*time.Second),
backoff.WithNotify(notify))
}
func (a *Core) signedPost(uri string, content []byte, response interface{}) (*http.Response, error) {
func (a *Core) signedPost(uri string, content []byte, response any) (*http.Response, error) {
signedContent, err := a.jws.SignContent(uri, content)
if err != nil {
return nil, fmt.Errorf("failed to post JWS message: failed to sign content: %w", err)
@ -157,6 +155,7 @@ func getDirectory(do *sender.Doer, caDirURL string) (acme.Directory, error) {
if dir.NewAccountURL == "" {
return dir, errors.New("directory missing new registration URL")
}
if dir.NewOrderURL == "" {
return dir, errors.New("directory missing new order URL")
}

View file

@ -15,10 +15,12 @@ func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error)
}
var authz acme.Authorization
_, err := c.core.postAsGet(authzURL, &authz)
if err != nil {
return acme.Authorization{}, err
}
return authz, nil
}
@ -29,6 +31,8 @@ func (c *AuthorizationService) Deactivate(authzURL string) error {
}
var disabledAuth acme.Authorization
_, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth)
return err
}

View file

@ -2,15 +2,12 @@ package api
import (
"bytes"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/log"
)
// maxBodySize is the maximum size of body that we will read.
@ -77,62 +74,22 @@ func (c *CertificateService) get(certURL string, bundle bool) (*acme.RawCertific
return nil, resp.Header, err
}
cert := c.getCertificateChain(data, resp.Header, bundle, certURL)
cert := c.getCertificateChain(data, bundle)
return cert, resp.Header, err
}
// getCertificateChain Returns the certificate and the issuer certificate.
func (c *CertificateService) getCertificateChain(cert []byte, headers http.Header, bundle bool, certURL string) *acme.RawCertificate {
func (c *CertificateService) getCertificateChain(cert []byte, bundle bool) *acme.RawCertificate {
// Get issuerCert from bundled response from Let's Encrypt
// See https://community.letsencrypt.org/t/acme-v2-no-up-link-in-response/64962
_, issuer := pem.Decode(cert)
if issuer != nil {
// If bundle is false, we want to return a single certificate.
// To do this, we remove the issuer cert(s) from the issued cert.
if !bundle {
cert = bytes.TrimSuffix(cert, issuer)
}
return &acme.RawCertificate{Cert: cert, Issuer: issuer}
}
// The issuer certificate link may be supplied via an "up" link
// in the response headers of a new certificate.
// See https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2
up := getLink(headers, "up")
issuer, err := c.getIssuerFromLink(up)
if err != nil {
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
log.Warnf("acme: Could not bundle issuer certificate [%s]: %v", certURL, err)
} else if len(issuer) > 0 {
// If bundle is true, we want to return a certificate bundle.
// To do this, we append the issuer cert to the issued cert.
if bundle {
cert = append(cert, issuer...)
}
// If bundle is false, we want to return a single certificate.
// To do this, we remove the issuer cert(s) from the issued cert.
if !bundle {
cert = bytes.TrimSuffix(cert, issuer)
}
return &acme.RawCertificate{Cert: cert, Issuer: issuer}
}
// getIssuerFromLink requests the issuer certificate.
func (c *CertificateService) getIssuerFromLink(up string) ([]byte, error) {
if up == "" {
return nil, nil
}
log.Infof("acme: Requesting issuer cert from %s", up)
cert, _, err := c.get(up, false)
if err != nil {
return nil, err
}
_, err = x509.ParseCertificate(cert.Cert)
if err != nil {
return nil, err
}
return certcrypto.PEMEncode(certcrypto.DERCertificateBytes(cert.Cert)), nil
}

View file

@ -3,11 +3,10 @@ package api
import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"net/http"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -74,56 +73,34 @@ rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
`
func TestCertificateService_Get_issuerRelUp(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`)
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) {
p, _ := pem.Decode([]byte(issuerMock))
_, err := w.Write(p.Bytes)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true)
cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
}
func TestCertificateService_Get_embeddedIssuer(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true)
cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")

View file

@ -17,6 +17,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
// Challenge initiation is done by sending a JWS payload containing the trivial JSON object `{}`.
// We use an empty struct instance as the postJSON payload here to achieve this result.
var chlng acme.ExtendedChallenge
resp, err := c.core.post(chlgURL, struct{}{}, &chlng)
if err != nil {
return acme.ExtendedChallenge{}, err
@ -24,6 +25,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
return chlng, nil
}
@ -34,6 +36,7 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) {
}
var chlng acme.ExtendedChallenge
resp, err := c.core.postAsGet(chlgURL, &chlng)
if err != nil {
return acme.ExtendedChallenge{}, err
@ -41,5 +44,6 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) {
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
return chlng, nil
}

52
acme/api/identifier.go Normal file
View file

@ -0,0 +1,52 @@
package api
import (
"cmp"
"net"
"slices"
"github.com/go-acme/lego/v4/acme"
)
func createIdentifiers(domains []string) []acme.Identifier {
uniqIdentifiers := make(map[string]struct{})
var identifiers []acme.Identifier
for _, domain := range domains {
if _, ok := uniqIdentifiers[domain]; ok {
continue
}
ident := acme.Identifier{Value: domain, Type: "dns"}
if net.ParseIP(domain) != nil {
ident.Type = "ip"
}
identifiers = append(identifiers, ident)
uniqIdentifiers[domain] = struct{}{}
}
return identifiers
}
// compareIdentifiers compares 2 slices of [acme.Identifier].
func compareIdentifiers(a, b []acme.Identifier) int {
// Clones slices to avoid modifying original slices.
right := slices.Clone(a)
left := slices.Clone(b)
slices.SortStableFunc(right, compareIdentifier)
slices.SortStableFunc(left, compareIdentifier)
return slices.CompareFunc(right, left, compareIdentifier)
}
func compareIdentifier(right, left acme.Identifier) int {
return cmp.Or(
cmp.Compare(right.Type, left.Type),
cmp.Compare(right.Value, left.Value),
)
}

111
acme/api/identifier_test.go Normal file
View file

@ -0,0 +1,111 @@
package api
import (
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/stretchr/testify/assert"
)
func Test_compareIdentifiers(t *testing.T) {
testCases := []struct {
desc string
a, b []acme.Identifier
expected int
}{
{
desc: "identical identifiers",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
expected: 0,
},
{
desc: "identical identifiers but different order",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "*.example.com"},
{Type: "dns", Value: "example.com"},
},
expected: 0,
},
{
desc: "duplicate identifiers",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "example.com"},
},
expected: -1,
},
{
desc: "different identifier values",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.org"},
},
expected: -1,
},
{
desc: "different identifier types",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "ip", Value: "*.example.com"},
},
expected: -1,
},
{
desc: "different number of identifiers a>b",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
{Type: "dns", Value: "example.org"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
expected: 1,
},
{
desc: "different number of identifiers b>a",
a: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
},
b: []acme.Identifier{
{Type: "dns", Value: "example.com"},
{Type: "dns", Value: "*.example.com"},
{Type: "dns", Value: "example.org"},
},
expected: -1,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
assert.Equal(t, test.expected, compareIdentifiers(test.a, test.b))
})
}
}

View file

@ -11,10 +11,11 @@ import (
// Manager Manages nonces.
type Manager struct {
sync.Mutex
do *sender.Doer
nonceURL string
nonces []string
sync.Mutex
}
// NewManager Creates a new Manager.
@ -36,6 +37,7 @@ func (n *Manager) Pop() (string, bool) {
nonce := n.nonces[len(n.nonces)-1]
n.nonces = n.nonces[:len(n.nonces)-1]
return nonce, true
}
@ -43,6 +45,7 @@ func (n *Manager) Pop() (string, bool) {
func (n *Manager) Push(nonce string) {
n.Lock()
defer n.Unlock()
n.nonces = append(n.nonces, nonce)
}
@ -51,6 +54,7 @@ func (n *Manager) Nonce() (string, error) {
if nonce, ok := n.Pop(); ok {
return nonce, nil
}
return n.getNonce()
}

View file

@ -8,45 +8,52 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
)
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(250 * time.Millisecond)
w.Header().Set("Replay-Nonce", "12345")
w.Header().Set("Retry-After", "0")
err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}))
t.Cleanup(server.Close)
manager := servermock.NewBuilder(
func(server *httptest.Server) (*Manager, error) {
doer := sender.NewDoer(server.Client(), "lego-test")
return NewManager(doer, server.URL), nil
}).
Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
time.Sleep(250 * time.Millisecond)
rw.Header().Set("Replay-Nonce", "12345")
rw.Header().Set("Retry-After", "0")
servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req)
})).
BuildHTTPS(t)
doer := sender.NewDoer(http.DefaultClient, "lego-test")
j := NewManager(doer, server.URL)
ch := make(chan bool)
resultCh := make(chan bool)
go func() {
_, errN := j.Nonce()
_, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
_, errN := j.Nonce()
_, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
<-ch
<-ch
resultCh <- true
}()
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):

View file

@ -36,6 +36,7 @@ func (j *JWS) SetKid(kid string) {
// SignContent Signs a content with the JWS.
func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, error) {
var alg jose.SignatureAlgorithm
switch k := j.privKey.(type) {
case *rsa.PrivateKey:
alg = jose.RS256
@ -54,7 +55,7 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e
options := jose.SignerOptions{
NonceSource: j.nonces,
ExtraHeaders: map[jose.HeaderKey]interface{}{
ExtraHeaders: map[jose.HeaderKey]any{
"url": url,
},
}
@ -72,12 +73,14 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e
if err != nil {
return nil, fmt.Errorf("failed to sign content: %w", err)
}
return signed, nil
}
// SignEABContent Signs an external account binding content with the JWS.
func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) {
jwk := jose.JSONWebKey{Key: j.privKey}
jwkJSON, err := jwk.Public().MarshalJSON()
if err != nil {
return nil, fmt.Errorf("acme: error encoding eab jwk key: %w", err)
@ -87,7 +90,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu
jose.SigningKey{Algorithm: jose.HS256, Key: hmac},
&jose.SignerOptions{
EmbedJWK: false,
ExtraHeaders: map[jose.HeaderKey]interface{}{
ExtraHeaders: map[jose.HeaderKey]any{
"kid": kid,
"url": url,
},
@ -108,6 +111,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu
// GetKeyAuthorization Gets the key authorization for a token.
func (j *JWS) GetKeyAuthorization(token string) (string, error) {
var publicKey crypto.PublicKey
switch k := j.privKey.(type) {
case *ecdsa.PrivateKey:
publicKey = k.Public()

View file

@ -9,45 +9,52 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/nonces"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
)
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(250 * time.Millisecond)
w.Header().Set("Replay-Nonce", "12345")
w.Header().Set("Retry-After", "0")
err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}))
t.Cleanup(server.Close)
manager := servermock.NewBuilder(
func(server *httptest.Server) (*nonces.Manager, error) {
doer := sender.NewDoer(server.Client(), "lego-test")
return nonces.NewManager(doer, server.URL), nil
}).
Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
time.Sleep(250 * time.Millisecond)
rw.Header().Set("Replay-Nonce", "12345")
rw.Header().Set("Retry-After", "0")
servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req)
})).
BuildHTTPS(t)
doer := sender.NewDoer(http.DefaultClient, "lego-test")
j := nonces.NewManager(doer, server.URL)
ch := make(chan bool)
resultCh := make(chan bool)
go func() {
_, errN := j.Nonce()
_, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
_, errN := j.Nonce()
_, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
<-ch
<-ch
resultCh <- true
}()
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):

View file

@ -27,6 +27,8 @@ type Doer struct {
// NewDoer Creates a new Doer.
func NewDoer(client *http.Client, userAgent string) *Doer {
client.Transport = newHTTPSOnly(client)
return &Doer{
httpClient: client,
userAgent: userAgent,
@ -35,7 +37,7 @@ func NewDoer(client *http.Client, userAgent string) *Doer {
// Get performs a GET request with a proper User-Agent string.
// If "response" is not provided, callers should close resp.Body when done reading from it.
func (d *Doer) Get(url string, response interface{}) (*http.Response, error) {
func (d *Doer) Get(url string, response any) (*http.Response, error) {
req, err := d.newRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@ -57,7 +59,7 @@ func (d *Doer) Head(url string) (*http.Response, error) {
// Post performs a POST request with a proper User-Agent string.
// If "response" is not provided, callers should close resp.Body when done reading from it.
func (d *Doer) Post(url string, body io.Reader, bodyType string, response interface{}) (*http.Response, error) {
func (d *Doer) Post(url string, body io.Reader, bodyType string, response any) (*http.Response, error) {
req, err := d.newRequest(http.MethodPost, url, body, contentType(bodyType))
if err != nil {
return nil, err
@ -84,7 +86,7 @@ func (d *Doer) newRequest(method, uri string, body io.Reader, opts ...RequestOpt
return req, nil
}
func (d *Doer) do(req *http.Request, response interface{}) (*http.Response, error) {
func (d *Doer) do(req *http.Request, response any) (*http.Response, error) {
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
@ -118,31 +120,69 @@ func (d *Doer) formatUserAgent() string {
}
func checkError(req *http.Request, resp *http.Response) error {
if resp.StatusCode >= http.StatusBadRequest {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err)
}
var errorDetails *acme.ProblemDetails
err = json.Unmarshal(body, &errorDetails)
if err != nil {
return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body))
}
errorDetails.Method = req.Method
errorDetails.URL = req.URL.String()
if errorDetails.HTTPStatus == 0 {
errorDetails.HTTPStatus = resp.StatusCode
}
// Check for errors we handle specifically
if errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr {
return &acme.NonceError{ProblemDetails: errorDetails}
if resp.StatusCode < http.StatusBadRequest {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err)
}
var errorDetails *acme.ProblemDetails
err = json.Unmarshal(body, &errorDetails)
if err != nil {
return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body))
}
errorDetails.Method = req.Method
errorDetails.URL = req.URL.String()
if errorDetails.HTTPStatus == 0 {
errorDetails.HTTPStatus = resp.StatusCode
}
// Check for errors we handle specifically
switch {
case errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr:
return &acme.NonceError{ProblemDetails: errorDetails}
case errorDetails.HTTPStatus == http.StatusConflict && errorDetails.Type == acme.AlreadyReplacedErr:
return &acme.AlreadyReplacedError{ProblemDetails: errorDetails}
case errorDetails.HTTPStatus == http.StatusTooManyRequests && errorDetails.Type == acme.RateLimitedErr:
return &acme.RateLimitedError{
ProblemDetails: errorDetails,
RetryAfter: resp.Header.Get("Retry-After"),
}
default:
return errorDetails
}
return nil
}
type httpsOnly struct {
rt http.RoundTripper
}
func newHTTPSOnly(client *http.Client) *httpsOnly {
if client.Transport == nil {
return &httpsOnly{rt: http.DefaultTransport}
}
return &httpsOnly{rt: client.Transport}
}
// RoundTrip ensure HTTPS is used.
// Each ACME function is accomplished by the client sending a sequence of HTTPS requests to the server [RFC2818],
// carrying JSON messages [RFC8259].
// Use of HTTPS is REQUIRED.
// https://datatracker.ietf.org/doc/html/rfc8555#section-6.1
func (r *httpsOnly) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" {
return nil, fmt.Errorf("HTTPS is required: %s", req.URL)
}
return r.rt.RoundTrip(req)
}

View file

@ -1,24 +1,28 @@
package sender
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDo_UserAgentOnAllHTTPMethod(t *testing.T) {
var ua, method string
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
server := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
t.Cleanup(server.Close)
doer := NewDoer(http.DefaultClient, "")
doer := NewDoer(server.Client(), "")
testCases := []struct {
method string
@ -60,8 +64,87 @@ func TestDo_CustomUserAgent(t *testing.T) {
ua := doer.formatUserAgent()
assert.Contains(t, ua, ourUserAgent)
assert.Contains(t, ua, customUA)
if strings.HasSuffix(ua, " ") {
t.Errorf("UA should not have trailing spaces; got '%s'", ua)
}
assert.Len(t, strings.Split(ua, " "), 5)
}
func TestDo_failWithHTTP(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
t.Cleanup(server.Close)
sender := NewDoer(server.Client(), "test")
_, err := sender.Post(server.URL, strings.NewReader("data"), "text/plain", nil)
require.ErrorContains(t, err, "HTTPS is required: http://")
}
func Test_checkError(t *testing.T) {
testCases := []struct {
desc string
resp *http.Response
assert func(t *testing.T, err error)
}{
{
desc: "default",
resp: &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:example","detail":"message","status":404}`)),
},
assert: errorAs[*acme.ProblemDetails],
},
{
desc: "badNonce",
resp: &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:badNonce","detail":"message","status":400}`)),
},
assert: errorAs[*acme.NonceError],
},
{
desc: "alreadyReplaced",
resp: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:alreadyReplaced","detail":"message","status":409}`)),
},
assert: errorAs[*acme.AlreadyReplacedError],
},
{
desc: "rateLimited",
resp: &http.Response{
StatusCode: http.StatusConflict,
Header: http.Header{
"Retry-After": []string{"1"},
},
Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:rateLimited","detail":"message","status":429}`)),
},
assert: errorAs[*acme.RateLimitedError],
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com", nil)
err := checkError(req, test.resp)
require.Error(t, err)
pb := &acme.ProblemDetails{}
assert.ErrorAs(t, err, &pb)
test.assert(t, err)
})
}
}
func errorAs[T error](t *testing.T, err error) {
t.Helper()
var zero T
assert.ErrorAs(t, err, &zero)
}

View file

@ -4,10 +4,10 @@ package sender
const (
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "xenolf-acme/4.20.2"
ourUserAgent = "xenolf-acme/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
ourUserAgentComment = "release"
ourUserAgentComment = "detach"
)

View file

@ -3,7 +3,8 @@ package api
import (
"encoding/base64"
"errors"
"net"
"fmt"
"slices"
"time"
"github.com/go-acme/lego/v4/acme"
@ -13,9 +14,15 @@ import (
type OrderOptions struct {
NotBefore time.Time
NotAfter time.Time
// A string uniquely identifying the profile
// which will be used to affect issuance of the certificate requested by this Order.
// - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
Profile string
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@ -28,18 +35,7 @@ func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
// NewWithOptions Creates a new order.
func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) {
var identifiers []acme.Identifier
for _, domain := range domains {
ident := acme.Identifier{Value: domain, Type: "dns"}
if net.ParseIP(domain) != nil {
ident.Type = "ip"
}
identifiers = append(identifiers, ident)
}
orderReq := acme.Order{Identifiers: identifiers}
orderReq := acme.Order{Identifiers: createIdentifiers(domains)}
if opts != nil {
if !opts.NotAfter.IsZero() {
@ -53,12 +49,50 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm
if o.core.GetDirectory().RenewalInfo != "" {
orderReq.Replaces = opts.ReplacesCertID
}
if opts.Profile != "" {
orderReq.Profile = opts.Profile
}
}
var order acme.Order
resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
if err != nil {
return acme.ExtendedOrder{}, err
are := &acme.AlreadyReplacedError{}
if !errors.As(err, &are) {
return acme.ExtendedOrder{}, err
}
// If the Server rejects the request because the identified certificate has already been marked as replaced,
// it MUST return an HTTP 409 (Conflict) with a problem document of type "alreadyReplaced" (see Section 7.4).
// https://www.rfc-editor.org/rfc/rfc9773.html#section-5
orderReq.Replaces = ""
resp, err = o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
if err != nil {
return acme.ExtendedOrder{}, err
}
}
// The server MUST return an error if it cannot fulfill the request as specified,
// and it MUST NOT issue a certificate with contents other than those requested.
// If the server requires the request to be modified in a certain way,
// it should indicate the required changes using an appropriate error type and description.
// https://www.rfc-editor.org/rfc/rfc8555#section-7.4
//
// Some ACME servers don't return an error,
// and/or change the order identifiers in the response,
// so we need to ensure that the identifiers are the same as requested.
// Deduplication by the server is allowed.
if compareIdentifiers(orderReq.Identifiers, order.Identifiers) != 0 {
// Sorts identifiers to avoid error message ambiguities about the order of the identifiers.
slices.SortStableFunc(orderReq.Identifiers, compareIdentifier)
slices.SortStableFunc(order.Identifiers, compareIdentifier)
return acme.ExtendedOrder{},
fmt.Errorf("order identifiers have been modified by the ACME server (RFC8555 §7.4): %+v != %+v",
orderReq.Identifiers, order.Identifiers)
}
return acme.ExtendedOrder{
@ -74,6 +108,7 @@ func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) {
}
var order acme.Order
_, err := o.core.postAsGet(orderURL, &order)
if err != nil {
return acme.ExtendedOrder{}, err
@ -89,13 +124,14 @@ func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedO
}
var order acme.Order
_, err := o.core.post(orderURL, csrMsg, &order)
if err != nil {
return acme.ExtendedOrder{}, err
}
if order.Status == acme.StatusInvalid {
return acme.ExtendedOrder{}, order.Error
return acme.ExtendedOrder{}, fmt.Errorf("invalid order: %w", order.Err())
}
return acme.ExtendedOrder{Order: order}, nil

View file

@ -11,55 +11,51 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderService_NewWithOptions(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
// small value keeps test fast
privateKey, errK := rsa.GenerateKey(rand.Reader, 512)
privateKey, errK := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, errK, "Could not generate test key")
mux.HandleFunc("/newOrder", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
server := tester.MockACMEServer().
Route("POST /newOrder",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
body, err := readSignedBody(req, privateKey)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
body, err := readSignedBody(r, privateKey)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
order := acme.Order{}
order := acme.Order{}
err = json.Unmarshal(body, &order)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = json.Unmarshal(body, &order)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
err = tester.WriteJSONResponse(w, acme.Order{
Status: acme.StatusValid,
Expires: order.Expires,
Identifiers: order.Identifiers,
NotBefore: order.NotBefore,
NotAfter: order.NotAfter,
Error: order.Error,
Authorizations: order.Authorizations,
Finalize: order.Finalize,
Certificate: order.Certificate,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
servermock.JSONEncode(acme.Order{
Status: acme.StatusValid,
Expires: order.Expires,
Identifiers: order.Identifiers,
Profile: order.Profile,
NotBefore: order.NotBefore,
NotAfter: order.NotAfter,
Error: order.Error,
Authorizations: order.Authorizations,
Finalize: order.Finalize,
Certificate: order.Certificate,
Replaces: order.Replaces,
}).ServeHTTP(rw, req)
})).
BuildHTTPS(t)
core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@ -112,6 +108,7 @@ func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error)
}
sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
if err != nil {
return nil, err

View file

@ -14,7 +14,7 @@ var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a re
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI

View file

@ -1,8 +1,11 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strconv"
"time"
)
type service struct {
@ -23,11 +26,13 @@ func getLinks(header http.Header, rel string) []string {
linkExpr := regexp.MustCompile(`<(.+?)>(?:;[^;]+)*?;\s*rel="(.+?)"`)
var links []string
for _, link := range header["Link"] {
for _, m := range linkExpr.FindAllStringSubmatch(link, -1) {
if len(m) != 3 {
continue
}
if m[2] == rel {
links = append(links, m[1])
}
@ -54,3 +59,29 @@ func getRetryAfter(resp *http.Response) string {
return resp.Header.Get("Retry-After")
}
// ParseRetryAfter parses the Retry-After header value according to RFC 7231.
// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format).
// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3
// Returns the duration until the retry time.
// TODO(ldez): unexposed this function in v5.
func ParseRetryAfter(value string) (time.Duration, error) {
if value == "" {
return 0, nil
}
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.Duration(seconds) * time.Second, nil
}
if retryTime, err := time.Parse(time.RFC1123, value); err == nil {
duration := time.Until(retryTime)
if duration < 0 {
return 0, nil
}
return duration, nil
}
return 0, fmt.Errorf("invalid Retry-After value: %q", value)
}

View file

@ -3,8 +3,10 @@ package api
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_getLink(t *testing.T) {
@ -53,3 +55,38 @@ func Test_getLink(t *testing.T) {
})
}
}
func TestParseRetryAfter(t *testing.T) {
testCases := []struct {
desc string
value string
expected time.Duration
}{
{
desc: "empty header value",
value: "",
expected: time.Duration(0),
},
{
desc: "delay-seconds",
value: "123",
expected: 123 * time.Second,
},
{
desc: "HTTP-date",
value: time.Now().Add(3 * time.Second).Format(time.RFC1123),
expected: 3 * time.Second,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
rt, err := ParseRetryAfter(test.value)
require.NoError(t, err)
assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1)
})
}
}

View file

@ -38,7 +38,7 @@ const (
// Directory the ACME directory object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1
// - https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
// - https://www.rfc-editor.org/rfc/rfc9773.html
type Directory struct {
NewNonceURL string `json:"newNonce"`
NewAccountURL string `json:"newAccount"`
@ -74,11 +74,17 @@ type Meta struct {
// then the CA requires that all new-account requests include an "externalAccountBinding" field
// associating the new account with an external account.
ExternalAccountRequired bool `json:"externalAccountRequired"`
// profiles (optional, object):
// A map of profile names to human-readable descriptions of those profiles.
// https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-3
Profiles map[string]string `json:"profiles"`
}
// ExtendedAccount an extended Account.
type ExtendedAccount struct {
Account
// Contains the value of the response header `Location`
Location string `json:"-"`
}
@ -148,6 +154,12 @@ type Order struct {
// An array of identifier objects that the order pertains to.
Identifiers []Identifier `json:"identifiers"`
// profile (string, optional):
// A string uniquely identifying the profile
// which will be used to affect issuance of the certificate requested by this Order.
// https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
Profile string `json:"profile,omitempty"`
// notBefore (optional, string):
// The requested value of the notBefore field in the certificate,
// in the date format defined in [RFC3339].
@ -185,10 +197,18 @@ type Order struct {
// replaces (optional, string):
// replaces (string, optional): A string uniquely identifying a
// previously-issued certificate which this order is intended to replace.
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
Replaces string `json:"replaces,omitempty"`
}
func (r *Order) Err() error {
if r.Error != nil {
return r.Error
}
return nil
}
// Authorization the ACME authorization object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4
type Authorization struct {
@ -201,11 +221,11 @@ type Authorization struct {
// The timestamp after which the server will consider this authorization invalid,
// encoded in the format specified in RFC 3339 [RFC3339].
// This field is REQUIRED for objects with "valid" in the "status" field.
Expires time.Time `json:"expires,omitempty"`
Expires time.Time `json:"expires,omitzero"`
// identifier (required, object):
// The identifier that the account is authorized to represent
Identifier Identifier `json:"identifier,omitempty"`
Identifier Identifier `json:"identifier"`
// challenges (required, array of objects):
// For pending authorizations, the challenges that the client can fulfill in order to prove possession of the identifier.
@ -225,6 +245,7 @@ type Authorization struct {
// ExtendedChallenge a extended Challenge.
type ExtendedChallenge struct {
Challenge
// Contains the value of the response header `Retry-After`
RetryAfter string `json:"-"`
// Contains the value of the response header `Link` rel="up"
@ -251,7 +272,7 @@ type Challenge struct {
// The time at which the server validated this challenge,
// encoded in the format specified in RFC 3339 [RFC3339].
// This field is REQUIRED if the "status" field is "valid".
Validated time.Time `json:"validated,omitempty"`
Validated time.Time `json:"validated,omitzero"`
// error (optional, object):
// Error that occurred while the server was validating the challenge, if any,
@ -274,6 +295,14 @@ type Challenge struct {
KeyAuthorization string `json:"keyAuthorization"`
}
func (c *Challenge) Err() error {
if c.Error != nil {
return c.Error
}
return nil
}
// Identifier the ACME identifier object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-9.7.7
type Identifier struct {
@ -322,7 +351,7 @@ type Window struct {
}
// RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint.
// - (4.1. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
// - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
type RenewalInfoResponse struct {
// SuggestedWindow contains two fields, start and end,
// whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate.
@ -335,11 +364,11 @@ type RenewalInfoResponse struct {
}
// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2
// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
type RenewalInfoUpdateRequest struct {
// CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the
// certificate's authority key identifier and Serial is the certificate's serial number. For details, see:
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1
// https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1
CertID string `json:"certID"`
// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,

View file

@ -2,12 +2,15 @@ package acme
import (
"fmt"
"strings"
)
// Errors types.
const (
errNS = "urn:ietf:params:acme:error:"
BadNonceErr = errNS + "badNonce"
errNS = "urn:ietf:params:acme:error:"
BadNonceErr = errNS + "badNonce"
AlreadyReplacedErr = errNS + "alreadyReplaced"
RateLimitedErr = errNS + "rateLimited"
)
// ProblemDetails the problem details object.
@ -25,30 +28,34 @@ type ProblemDetails struct {
URL string `json:"url,omitempty"`
}
func (p *ProblemDetails) Error() string {
msg := new(strings.Builder)
_, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus)
if p.Method != "" || p.URL != "" {
_, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL)
}
_, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail)
for _, sub := range p.SubProblems {
_, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail)
}
if p.Instance != "" {
msg.WriteString(", url: " + p.Instance)
}
return msg.String()
}
// SubProblem a "subproblems".
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.7.1
type SubProblem struct {
Type string `json:"type,omitempty"`
Detail string `json:"detail,omitempty"`
Identifier Identifier `json:"identifier,omitempty"`
}
func (p ProblemDetails) Error() string {
msg := fmt.Sprintf("acme: error: %d", p.HTTPStatus)
if p.Method != "" || p.URL != "" {
msg += fmt.Sprintf(" :: %s :: %s", p.Method, p.URL)
}
msg += fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail)
for _, sub := range p.SubProblems {
msg += fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail)
}
if p.Instance != "" {
msg += ", url: " + p.Instance
}
return msg
Identifier Identifier `json:"identifier"`
}
// NonceError represents the error which is returned
@ -56,3 +63,31 @@ func (p ProblemDetails) Error() string {
type NonceError struct {
*ProblemDetails
}
func (e *NonceError) Unwrap() error {
return e.ProblemDetails
}
// AlreadyReplacedError represents the error which is returned
// if the Server rejects the request because the identified certificate has already been marked as replaced.
// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
type AlreadyReplacedError struct {
*ProblemDetails
}
func (e *AlreadyReplacedError) Unwrap() error {
return e.ProblemDetails
}
// RateLimitedError represents the error which is returned
// if the server rejects the request because the client has exceeded the rate limit.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.6
type RateLimitedError struct {
*ProblemDetails
RetryAfter string
}
func (e *RateLimitedError) Unwrap() error {
return e.ProblemDetails
}

View file

@ -1,10 +1,12 @@
# syntax=docker/dockerfile:1.4
FROM alpine:3
ARG TARGETPLATFORM
RUN apk --no-cache --no-progress add git ca-certificates tzdata \
&& rm -rf /var/cache/apk/*
COPY lego /
COPY $TARGETPLATFORM/lego /
ENTRYPOINT ["/lego"]
EXPOSE 80

View file

@ -57,8 +57,10 @@ type DERCertificateBytes []byte
// ParsePEMBundle parses a certificate bundle from top to bottom and returns
// a slice of x509 certificates. This function will error if no certificates are found.
func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
var certificates []*x509.Certificate
var certDERBlock *pem.Block
var (
certificates []*x509.Certificate
certDERBlock *pem.Block
)
for {
certDERBlock, bundle = pem.Decode(bundle)
@ -71,6 +73,7 @@ func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
if err != nil {
return nil, err
}
certificates = append(certificates, cert)
}
}
@ -135,10 +138,29 @@ func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
return nil, fmt.Errorf("invalid KeyType: %s", keyType)
}
// Deprecated: uses [CreateCSR] instead.
func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) {
var dnsNames []string
var ipAddresses []net.IP
for _, altname := range san {
return CreateCSR(privateKey, CSROptions{
Domain: domain,
SAN: san,
MustStaple: mustStaple,
})
}
type CSROptions struct {
Domain string
SAN []string
MustStaple bool
EmailAddresses []string
}
func CreateCSR(privateKey crypto.PrivateKey, opts CSROptions) ([]byte, error) {
var (
dnsNames []string
ipAddresses []net.IP
)
for _, altname := range opts.SAN {
if ip := net.ParseIP(altname); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
@ -147,12 +169,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must
}
template := x509.CertificateRequest{
Subject: pkix.Name{CommonName: domain},
DNSNames: dnsNames,
IPAddresses: ipAddresses,
Subject: pkix.Name{CommonName: opts.Domain},
DNSNames: dnsNames,
EmailAddresses: opts.EmailAddresses,
IPAddresses: ipAddresses,
}
if mustStaple {
if opts.MustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: tlsFeatureExtensionOID,
Value: ocspMustStapleFeature,
@ -162,12 +185,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must
return x509.CreateCertificateRequest(rand.Reader, &template, privateKey)
}
func PEMEncode(data interface{}) []byte {
func PEMEncode(data any) []byte {
return pem.EncodeToMemory(PEMBlock(data))
}
func PEMBlock(data interface{}) *pem.Block {
func PEMBlock(data any) *pem.Block {
var pemBlock *pem.Block
switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
@ -218,15 +242,15 @@ func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) {
}
func GetCertificateMainDomain(cert *x509.Certificate) (string, error) {
return getMainDomain(cert.Subject, cert.DNSNames)
return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) {
return getMainDomain(cert.Subject, cert.DNSNames)
return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
if subject.CommonName == "" && len(dnsNames) == 0 {
func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) {
if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 {
return "", errors.New("missing domain")
}
@ -234,7 +258,11 @@ func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
return subject.CommonName, nil
}
return dnsNames[0], nil
if len(dnsNames) > 0 {
return dnsNames[0], nil
}
return ips[0].String(), nil
}
func ExtractDomains(cert *x509.Certificate) []string {
@ -248,6 +276,7 @@ func ExtractDomains(cert *x509.Certificate) []string {
if sanDomain == cert.Subject.CommonName {
continue
}
domains = append(domains, sanDomain)
}
@ -299,6 +328,7 @@ func GeneratePemCert(privateKey *rsa.PrivateKey, domain string, extensions []pki
func generateDerCert(privateKey *rsa.PrivateKey, expiration time.Time, domain string, extensions []pkix.Extension) ([]byte, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, err

View file

@ -6,7 +6,6 @@ import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"regexp"
"testing"
"time"
@ -14,6 +13,13 @@ import (
"github.com/stretchr/testify/require"
)
const (
testDomain1 = "lego.example"
testDomain2 = "a.lego.example"
testDomain3 = "b.lego.example"
testDomain4 = "c.lego.example"
)
func TestGeneratePrivateKey(t *testing.T) {
key, err := GeneratePrivateKey(RSA2048)
require.NoError(t, err, "Error generating private key")
@ -22,7 +28,7 @@ func TestGeneratePrivateKey(t *testing.T) {
}
func TestGenerateCSR(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Error generating private key")
type expected struct {
@ -33,55 +39,75 @@ func TestGenerateCSR(t *testing.T) {
testCases := []struct {
desc string
privateKey crypto.PrivateKey
domain string
san []string
mustStaple bool
opts CSROptions
expected expected
}{
{
desc: "without SAN (nil)",
privateKey: privateKey,
domain: "lego.acme",
mustStaple: true,
expected: expected{len: 245},
opts: CSROptions{
Domain: testDomain1,
MustStaple: true,
},
expected: expected{len: 382},
},
{
desc: "without SAN (empty)",
privateKey: privateKey,
domain: "lego.acme",
san: []string{},
mustStaple: true,
expected: expected{len: 245},
opts: CSROptions{
Domain: testDomain1,
SAN: []string{},
MustStaple: true,
},
expected: expected{len: 382},
},
{
desc: "with SAN",
privateKey: privateKey,
domain: "lego.acme",
san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"},
mustStaple: true,
expected: expected{len: 296},
opts: CSROptions{
Domain: testDomain1,
SAN: []string{testDomain2, testDomain3, testDomain4},
MustStaple: true,
},
expected: expected{len: 442},
},
{
desc: "no domain",
privateKey: privateKey,
domain: "",
mustStaple: true,
expected: expected{len: 225},
opts: CSROptions{
Domain: "",
MustStaple: true,
},
expected: expected{len: 359},
},
{
desc: "no domain with SAN",
privateKey: privateKey,
domain: "",
san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"},
mustStaple: true,
expected: expected{len: 276},
opts: CSROptions{
Domain: "",
SAN: []string{testDomain2, testDomain3, testDomain4},
MustStaple: true,
},
expected: expected{len: 419},
},
{
desc: "private key nil",
privateKey: nil,
domain: "fizz.buzz",
mustStaple: true,
expected: expected{error: true},
opts: CSROptions{
Domain: testDomain1,
MustStaple: true,
},
expected: expected{error: true},
},
{
desc: "with email addresses",
privateKey: privateKey,
opts: CSROptions{
Domain: "example.com",
SAN: []string{"example.org"},
EmailAddresses: []string{"foo@example.com", "bar@example.com"},
},
expected: expected{len: 421},
},
}
@ -89,7 +115,7 @@ func TestGenerateCSR(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
csr, err := GenerateCSR(test.privateKey, test.domain, test.san, test.mustStaple)
csr, err := CreateCSR(test.privateKey, test.opts)
if test.expected.error {
require.Error(t, err)
@ -104,17 +130,17 @@ func TestGenerateCSR(t *testing.T) {
}
func TestPEMEncode(t *testing.T) {
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
reader := MockRandReader{b: buf}
key, err := rsa.GenerateKey(reader, 32)
key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Error generating private key")
data := PEMEncode(key)
require.NotNil(t, data)
exp := regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----\s+\S{60,}\s+-----END RSA PRIVATE KEY-----\s+`)
assert.Regexp(t, exp, string(data))
p, rest := pem.Decode(data)
assert.Equal(t, "RSA PRIVATE KEY", p.Type)
assert.Empty(t, rest)
assert.Empty(t, p.Headers)
}
func TestParsePEMCertificate(t *testing.T) {
@ -149,10 +175,13 @@ func TestParsePEMPrivateKey(t *testing.T) {
pemPrivateKey := PEMEncode(privateKey)
// Decoding a key should work and create an identical key to the original
// Decoding a key should work and create an identical RSA key to the original,
// ignoring precomputed values.
decoded, err := ParsePEMPrivateKey(pemPrivateKey)
require.NoError(t, err)
assert.Equal(t, decoded, privateKey)
decodedRsaPrivateKey := decoded.(*rsa.PrivateKey)
require.True(t, decodedRsaPrivateKey.Equal(privateKey))
// Decoding a PEM block that doesn't contain a private key should error
_, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE"}))
@ -166,11 +195,3 @@ func TestParsePEMPrivateKey(t *testing.T) {
_, err = ParsePEMPrivateKey([]byte("This is not PEM"))
require.Errorf(t, err, "Expected to return an error for non-PEM input")
}
type MockRandReader struct {
b *bytes.Buffer
}
func (r MockRandReader) Read(p []byte) (int, error) {
return r.b.Read(p)
}

View file

@ -29,6 +29,7 @@ func (c *Certifier) getAuthorizations(order acme.ExtendedOrder) ([]acme.Authoriz
var responses []acme.Authorization
failures := newObtainError()
for range len(order.Authorizations) {
select {
case res := <-resc:
@ -52,7 +53,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo
for _, authzURL := range order.Authorizations {
auth, err := c.core.Authorizations.Get(authzURL)
if err != nil {
log.Infof("Unable to get the authorization for: %s", authzURL)
log.Infof("Unable to get the authorization for %s: %v", authzURL, err)
continue
}
@ -62,6 +63,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo
}
log.Infof("Deactivating auth: %s", authzURL)
if c.core.Authorizations.Deactivate(authzURL) != nil {
log.Infof("Unable to deactivate the authorization: %s", authzURL)
}

View file

@ -65,18 +65,26 @@ type Resource struct {
// If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful.
// See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2.
type ObtainRequest struct {
Domains []string
PrivateKey crypto.PrivateKey
MustStaple bool
Domains []string
PrivateKey crypto.PrivateKey
MustStaple bool
EmailAddresses []string
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
// A string uniquely identifying the profile
// which will be used to affect issuance of the certificate requested by this Order.
// - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
Profile string
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@ -89,14 +97,23 @@ type ObtainRequest struct {
type ObtainForCSRRequest struct {
CSR *x509.CertificateRequest
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
PrivateKey crypto.PrivateKey
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
// A string uniquely identifying the profile
// which will be used to affect issuance of the certificate requested by this Order.
// - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
Profile string
AlwaysDeactivateAuthorizations bool
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@ -108,6 +125,7 @@ type CertifierOptions struct {
KeyType certcrypto.KeyType
Timeout time.Duration
OverallRequestLimit int
DisableCommonName bool
}
// Certifier A service to obtain/renew/revoke certificates.
@ -154,6 +172,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
Profile: request.Profile,
ReplacesCertID: request.ReplacesCertID,
}
@ -179,7 +198,8 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := newObtainError()
cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain)
cert, err := c.getForOrder(domains, order, request)
if err != nil {
for _, auth := range authz {
failures.Add(challenge.GetTargetedDomain(auth), err)
@ -220,6 +240,7 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
Profile: request.Profile,
ReplacesCertID: request.ReplacesCertID,
}
@ -245,7 +266,13 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := newObtainError()
cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, nil, request.PreferredChain)
var privateKey []byte
if request.PrivateKey != nil {
privateKey = certcrypto.PEMEncode(request.PrivateKey)
}
cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, privateKey, request.PreferredChain)
if err != nil {
for _, auth := range authz {
failures.Add(challenge.GetTargetedDomain(auth), err)
@ -264,9 +291,12 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
return cert, failures.Join()
}
func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) {
func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, request ObtainRequest) (*Resource, error) {
privateKey := request.PrivateKey
if privateKey == nil {
var err error
privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType)
if err != nil {
return nil, err
@ -274,7 +304,7 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund
}
commonName := ""
if len(domains[0]) <= 64 {
if len(domains[0]) <= 64 && !c.options.DisableCommonName {
commonName = domains[0]
}
@ -296,13 +326,19 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund
}
}
// TODO: should the CSR be customizable?
csr, err := certcrypto.GenerateCSR(privateKey, commonName, san, mustStaple)
csrOptions := certcrypto.CSROptions{
Domain: commonName,
SAN: san,
MustStaple: request.MustStaple,
EmailAddresses: request.EmailAddresses,
}
csr, err := certcrypto.CreateCSR(privateKey, csrOptions)
if err != nil {
return nil, err
}
return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain)
return c.getForCSR(domains, order, request.Bundle, csr, certcrypto.PEMEncode(privateKey), request.PreferredChain)
}
func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) {
@ -435,11 +471,15 @@ type RenewOptions struct {
NotBefore time.Time
NotAfter time.Time
// If true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
Bundle bool
PreferredChain string
Bundle bool
PreferredChain string
Profile string
AlwaysDeactivateAuthorizations bool
// Not supported for CSR request.
MustStaple bool
MustStaple bool
EmailAddresses []string
}
// Renew takes a Resource and tries to renew the certificate.
@ -452,6 +492,7 @@ type RenewOptions struct {
// If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
//
// For private key reuse the PrivateKey property of the passed in Resource should be non-nil.
//
// Deprecated: use RenewWithOptions instead.
func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) {
return c.RenewWithOptions(certRes, &RenewOptions{
@ -505,6 +546,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.Profile = options.Profile
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
@ -530,6 +572,8 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.EmailAddresses = options.EmailAddresses
request.Profile = options.Profile
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
@ -668,7 +712,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) {
case acme.StatusValid:
return true, nil
case acme.StatusInvalid:
return false, order.Error
return false, fmt.Errorf("invalid order: %w", order.Err())
default:
return false, nil
}
@ -681,6 +725,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) {
// https://www.rfc-editor.org/rfc/rfc5280.html#section-7
func sanitizeDomain(domains []string) []string {
var sanitizedDomains []string
for _, domain := range domains {
sanitizedDomain, err := idna.ToASCII(domain)
if err != nil {
@ -689,5 +734,6 @@ func sanitizeDomain(domains []string) []string {
sanitizedDomains = append(sanitizedDomains, sanitizedDomain)
}
}
return sanitizedDomains
}

View file

@ -3,7 +3,6 @@ package certificate
import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"fmt"
"net/http"
"testing"
@ -12,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -175,20 +175,14 @@ Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
`
func Test_checkResponse(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@ -196,7 +190,7 @@ func Test_checkResponse(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@ -205,7 +199,7 @@ func Test_checkResponse(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@ -215,30 +209,14 @@ func Test_checkResponse(t *testing.T) {
}
func Test_checkResponse_issuerRelUp(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`)
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) {
p, _ := pem.Decode([]byte(issuerMock))
_, err := w.Write(p.Bytes)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@ -246,7 +224,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@ -255,7 +233,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@ -265,20 +243,14 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
}
func Test_checkResponse_no_bundle(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@ -286,7 +258,7 @@ func Test_checkResponse_no_bundle(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@ -295,7 +267,7 @@ func Test_checkResponse_no_bundle(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@ -305,30 +277,21 @@ func Test_checkResponse_no_bundle(t *testing.T) {
}
func Test_checkResponse_alternate(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().
Route("POST /certificate",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Add("Link",
fmt.Sprintf(`<https://%s/certificate/1>;title="foo";rel="alternate"`, req.Context().Value(http.LocalAddrContextKey)))
mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Link", fmt.Sprintf(`<%s/certificate/1>;title="foo";rel="alternate"`, apiURL))
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
mux.HandleFunc("/certificate/1", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(certResponseMock2))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
servermock.RawStringResponse(certResponseMock).ServeHTTP(rw, req)
})).
Route("/certificate/1", servermock.RawStringResponse(certResponseMock2)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@ -336,7 +299,7 @@ func Test_checkResponse_alternate(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{
@ -358,37 +321,76 @@ func Test_checkResponse_alternate(t *testing.T) {
}
func Test_Get(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/acme/cert/test-cert", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(certResponseMock))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
server := tester.MockACMEServer().
Route("POST /acme/cert/test-cert", servermock.RawStringResponse(certResponseMock)).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
certRes, err := certifier.Get(apiURL+"/acme/cert/test-cert", true)
certRes, err := certifier.Get(server.URL+"/acme/cert/test-cert", true)
require.NoError(t, err)
assert.NotNil(t, certRes)
assert.Equal(t, "acme.wtf", certRes.Domain)
assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertStableURL)
assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertURL)
assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertStableURL)
assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertURL)
assert.Nil(t, certRes.CSR)
assert.Nil(t, certRes.PrivateKey)
assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate")
assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate")
}
func Test_checkOrderStatus(t *testing.T) {
testCases := []struct {
desc string
order acme.Order
requireErr require.ErrorAssertionFunc
expected bool
}{
{
desc: "status valid",
order: acme.Order{Status: acme.StatusValid},
requireErr: require.NoError,
expected: true,
},
{
desc: "status invalid",
order: acme.Order{Status: acme.StatusInvalid},
requireErr: require.Error,
expected: false,
},
{
desc: "status invalid with error",
order: acme.Order{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}},
requireErr: require.Error,
expected: false,
},
{
desc: "unknown status",
order: acme.Order{Status: "foo"},
requireErr: require.NoError,
expected: false,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
status, err := checkOrderStatus(acme.ExtendedOrder{Order: test.order})
test.requireErr(t, err)
assert.Equal(t, test.expected, status)
})
}
}
type resolverMock struct {
error error
}

View file

@ -11,6 +11,7 @@ import (
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
)
// RenewalInfoRequest contains the necessary renewal information.
@ -25,15 +26,15 @@ type RenewalInfoResponse struct {
// RetryAfter header indicating the polling interval that the ACME server recommends.
// Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed,
// as the server may provide a different suggestedWindow.
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2
// https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
RetryAfter time.Duration
}
// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep.
// It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time.
// This method implements the RECOMMENDED algorithm described in draft-ietf-acme-ari.
// This method implements the RECOMMENDED algorithm described in RFC 9773.
//
// - (4.1-11. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
// - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
// Explicitly convert all times to UTC.
now = now.UTC()
@ -71,7 +72,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
certID, err := MakeARICertID(req.Cert)
if err != nil {
@ -85,22 +86,23 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse
defer resp.Body.Close()
var info RenewalInfoResponse
err = json.NewDecoder(resp.Body).Decode(&info)
if err != nil {
return nil, err
}
if retry := resp.Header.Get("Retry-After"); retry != "" {
info.RetryAfter, err = time.ParseDuration(retry + "s")
info.RetryAfter, err = api.ParseRetryAfter(retry)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to parse Retry-After header: %w", err)
}
}
return &info, nil
}
// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-03, section 4.1.
// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1.
func MakeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")

View file

@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -42,31 +43,24 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
require.NoError(t, err)
// Test with a fake API.
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/renewalInfo/"+ariLeafCertID, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "21600")
w.WriteHeader(http.StatusOK)
_, wErr := w.Write([]byte(`{
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca/docs/renewal-advice/"
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`))
require.NoError(t, wErr)
})
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", "21600")).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@ -76,10 +70,46 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca/docs/renewal-advice/", ri.ExplanationURL)
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf})
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001)
}
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
@ -88,24 +118,23 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
desc string
httpClient *http.Client
request RenewalInfoRequest
handler http.HandlerFunc
desc string
timeout time.Duration
request RenewalInfoRequest
handler http.HandlerFunc
}{
{
desc: "API timeout",
httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms.
request: RenewalInfoRequest{leaf},
desc: "API timeout",
timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms.
request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that takes 2ms to respond.
time.Sleep(2 * time.Millisecond)
},
},
{
desc: "API error",
httpClient: http.DefaultClient,
request: RenewalInfoRequest{leaf},
desc: "API error",
request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that responds with error instead of renewal info.
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -117,10 +146,17 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler)
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID, test.handler).
BuildHTTPS(t)
core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key)
client := server.Client()
if test.timeout != 0 {
client.Timeout = test.timeout
}
core, err := api.New(client, "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})

View file

@ -40,5 +40,6 @@ func GetTargetedDomain(authz acme.Authorization) string {
if authz.Wildcard {
return "*." + authz.Identifier.Value
}
return authz.Identifier.Value
}

View file

@ -40,6 +40,7 @@ func CondOption(condition bool, opt ChallengeOption) ChallengeOption {
return nil
}
}
return opt
}
@ -118,6 +119,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
info := GetChallengeInfo(authz.Identifier.Value, keyAuth)
var timeout, interval time.Duration
switch provider := c.provider.(type) {
case challenge.ProviderTimeout:
timeout, interval = provider.Timeout()
@ -134,6 +136,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if !stop || errP != nil {
log.Infof("[%s] acme: Waiting for DNS record propagation.", domain)
}
return stop, errP
})
if err != nil {
@ -141,6 +144,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}
@ -165,6 +169,7 @@ func (c *Challenge) Sequential() (bool, time.Duration) {
if p, ok := c.provider.(sequential); ok {
return ok, p.Sequential()
}
return false, 0
}
@ -173,6 +178,7 @@ type sequential interface {
}
// GetRecord returns a DNS record which will fulfill the `dns-01` challenge.
//
// Deprecated: use GetChallengeInfo instead.
func GetRecord(domain, keyAuth string) (fqdn, value string) {
info := GetChallengeInfo(domain, keyAuth)

View file

@ -12,9 +12,14 @@ const (
)
// DNSProviderManual is an implementation of the ChallengeProvider interface.
// TODO(ldez): move this to providers/dns/manual
//
// Deprecated: Use the manual.DNSProvider instead.
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
//
// Deprecated: Use the manual.NewDNSProvider instead.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}

View file

@ -4,7 +4,6 @@ import (
"crypto/rand"
"crypto/rsa"
"errors"
"net/http"
"testing"
"time"
@ -12,6 +11,8 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/dnsmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -32,12 +33,12 @@ func (p *providerTimeoutMock) CleanUp(domain, token, keyAuth string) error { ret
func (p *providerTimeoutMock) Timeout() (time.Duration, time.Duration) { return p.timeout, p.interval }
func TestChallenge_PreSolve(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@ -114,12 +115,16 @@ func TestChallenge_PreSolve(t *testing.T) {
}
func TestChallenge_Solve(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
useAsNameserver(t, dnsmock.NewServer().
Query("_acme-challenge.example.com. CNAME", dnsmock.Noop).
Build(t))
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
server := tester.MockACMEServer().BuildHTTPS(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@ -179,6 +184,7 @@ func TestChallenge_Solve(t *testing.T) {
if test.preCheck != nil {
options = append(options, WrapPreCheck(test.preCheck))
}
chlg := NewChallenge(core, test.validate, test.provider, options...)
authz := acme.Authorization{
@ -201,12 +207,12 @@ func TestChallenge_Solve(t *testing.T) {
}
func TestChallenge_CleanUp(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@ -281,3 +287,55 @@ func TestChallenge_CleanUp(t *testing.T) {
})
}
}
func TestGetChallengeInfo(t *testing.T) {
useAsNameserver(t, dnsmock.NewServer().
Query("_acme-challenge.example.com. CNAME", dnsmock.Noop).
Build(t))
info := GetChallengeInfo("example.com", "123")
expected := ChallengeInfo{
FQDN: "_acme-challenge.example.com.",
EffectiveFQDN: "_acme-challenge.example.com.",
Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
}
assert.Equal(t, expected, info)
}
func TestGetChallengeInfo_CNAME(t *testing.T) {
useAsNameserver(t, dnsmock.NewServer().
Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")).
Query("example.org. CNAME", dnsmock.Noop).
Build(t))
info := GetChallengeInfo("example.com", "123")
expected := ChallengeInfo{
FQDN: "_acme-challenge.example.com.",
EffectiveFQDN: "example.org.",
Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
}
assert.Equal(t, expected, info)
}
func TestGetChallengeInfo_CNAME_disabled(t *testing.T) {
useAsNameserver(t, dnsmock.NewServer().
// Never called when the env var works.
Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")).
Build(t))
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
info := GetChallengeInfo("example.com", "123")
expected := ChallengeInfo{
FQDN: "_acme-challenge.example.com.",
EffectiveFQDN: "_acme-challenge.example.com.",
Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
}
assert.Equal(t, expected, info)
}

View file

@ -1,4 +1,4 @@
domain company.com
domain example.com
nameserver 10.200.3.249
nameserver 10.200.3.250:5353
nameserver 2001:4860:4860::8844

View file

@ -1,12 +1,16 @@
package dns01
import (
"iter"
"github.com/miekg/dns"
)
// ToFqdn converts the name into a fqdn appending a trailing dot.
//
// Deprecated: Use [github.com/miekg/dns.Fqdn] directly.
func ToFqdn(name string) string {
n := len(name)
if n == 0 || name[n-1] == '.' {
return name
}
return name + "."
return dns.Fqdn(name)
}
// UnFqdn converts the fqdn into a name removing the trailing dot.
@ -15,5 +19,36 @@ func UnFqdn(name string) string {
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
return name
}
// UnFqdnDomainsSeq generates a sequence of "unFQDNed" domain names derived from a domain (FQDN or not) in descending order.
func UnFqdnDomainsSeq(fqdn string) iter.Seq[string] {
return func(yield func(string) bool) {
if fqdn == "" {
return
}
for _, index := range dns.Split(fqdn) {
if !yield(UnFqdn(fqdn[index:])) {
return
}
}
}
}
// DomainsSeq generates a sequence of domain names derived from a domain (FQDN or not) in descending order.
func DomainsSeq(fqdn string) iter.Seq[string] {
return func(yield func(string) bool) {
if fqdn == "" {
return
}
for _, index := range dns.Split(fqdn) {
if !yield(fqdn[index:]) {
return
}
}
}
}

View file

@ -1,39 +1,12 @@
package dns01
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
)
func TestToFqdn(t *testing.T) {
testCases := []struct {
desc string
domain string
expected string
}{
{
desc: "simple",
domain: "foo.example.com",
expected: "foo.example.com.",
},
{
desc: "already FQDN",
domain: "foo.example.com.",
expected: "foo.example.com.",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
fqdn := ToFqdn(test.domain)
assert.Equal(t, test.expected, fqdn)
})
}
}
func TestUnFqdn(t *testing.T) {
testCases := []struct {
desc string
@ -62,3 +35,103 @@ func TestUnFqdn(t *testing.T) {
})
}
}
func TestUnFqdnDomainsSeq(t *testing.T) {
testCases := []struct {
desc string
fqdn string
expected []string
}{
{
desc: "empty",
fqdn: "",
expected: nil,
},
{
desc: "TLD",
fqdn: "com",
expected: []string{"com"},
},
{
desc: "2 levels",
fqdn: "example.com",
expected: []string{"example.com", "com"},
},
{
desc: "3 levels",
fqdn: "foo.example.com",
expected: []string{"foo.example.com", "example.com", "com"},
},
}
for _, test := range testCases {
for name, suffix := range map[string]string{"": "", " FQDN": "."} { //nolint:gocritic
t.Run(test.desc+name, func(t *testing.T) {
t.Parallel()
actual := slices.Collect(UnFqdnDomainsSeq(test.fqdn + suffix))
assert.Equal(t, test.expected, actual)
})
}
}
}
func TestDomainsSeq(t *testing.T) {
testCases := []struct {
desc string
fqdn string
expected []string
}{
{
desc: "empty",
fqdn: "",
expected: nil,
},
{
desc: "empty FQDN",
fqdn: ".",
expected: nil,
},
{
desc: "TLD FQDN",
fqdn: "com",
expected: []string{"com"},
},
{
desc: "TLD",
fqdn: "com.",
expected: []string{"com."},
},
{
desc: "2 levels",
fqdn: "example.com",
expected: []string{"example.com", "com"},
},
{
desc: "2 levels FQDN",
fqdn: "example.com.",
expected: []string{"example.com.", "com."},
},
{
desc: "3 levels",
fqdn: "foo.example.com",
expected: []string{"foo.example.com", "example.com", "com"},
},
{
desc: "3 levels FQDN",
fqdn: "foo.example.com.",
expected: []string{"foo.example.com.", "example.com.", "com."},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
actual := slices.Collect(DomainsSeq(test.fqdn))
assert.Equal(t, test.expected, actual)
})
}
}

View file

@ -0,0 +1,81 @@
package dns01
import (
"context"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
func fakeNS(name, ns string) *dns.NS {
return &dns.NS{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 172800},
Ns: ns,
}
}
func fakeA(name, ip string) *dns.A {
return &dns.A{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 10},
A: net.ParseIP(ip),
}
}
func fakeTXT(name, value string) *dns.TXT {
return &dns.TXT{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 10},
Txt: []string{value},
}
}
// mockResolver modifies the default DNS resolver to use a custom network address during the test execution.
// IMPORTANT: it modifying global variables.
func mockResolver(t *testing.T, addr net.Addr) {
t.Helper()
_, port, err := net.SplitHostPort(addr.String())
require.NoError(t, err)
originalDefaultNameserverPort := defaultNameserverPort
t.Cleanup(func() {
defaultNameserverPort = originalDefaultNameserverPort
})
defaultNameserverPort = port
originalResolver := net.DefaultResolver
t.Cleanup(func() {
net.DefaultResolver = originalResolver
})
net.DefaultResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 1 * time.Second}
return d.DialContext(ctx, network, addr.String())
},
}
}
func useAsNameserver(t *testing.T, addr net.Addr) {
t.Helper()
ClearFqdnCache()
t.Cleanup(func() {
ClearFqdnCache()
})
originalRecursiveNameservers := recursiveNameservers
t.Cleanup(func() {
recursiveNameservers = originalRecursiveNameservers
})
recursiveNameservers = ParseNameservers([]string{addr.String()})
}

View file

@ -81,6 +81,7 @@ func getNameservers(path string, defaults []string) []string {
func ParseNameservers(servers []string) []string {
var resolvers []string
for _, resolver := range servers {
// ensure all servers have a port number
if _, _, err := net.SplitHostPort(resolver); err != nil {
@ -89,6 +90,7 @@ func ParseNameservers(servers []string) []string {
resolvers = append(resolvers, resolver)
}
}
return resolvers
}
@ -132,6 +134,7 @@ func FindPrimaryNsByFqdnCustom(fqdn string, nameservers []string) (string, error
if err != nil {
return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err)
}
return soa.primaryNs, nil
}
@ -148,6 +151,7 @@ func FindZoneByFqdnCustom(fqdn string, nameservers []string) (string, error) {
if err != nil {
return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err)
}
return soa.zone, nil
}
@ -172,13 +176,12 @@ func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error)
}
func fetchSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) {
var err error
var r *dns.Msg
labelIndexes := dns.Split(fqdn)
for _, index := range labelIndexes {
domain := fqdn[index:]
var (
err error
r *dns.Msg
)
for domain := range DomainsSeq(fqdn) {
r, err = dnsQuery(domain, dns.TypeSOA, nameservers, true)
if err != nil {
continue
@ -232,9 +235,11 @@ func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) (
return nil, &DNSError{Message: "empty list of nameservers"}
}
var r *dns.Msg
var err error
var errAll error
var (
r *dns.Msg
err error
errAll error
)
for _, ns := range nameservers {
r, err = sendDNSQuery(m, ns)
@ -267,6 +272,7 @@ func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg {
func sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) {
if ok, _ := strconv.ParseBool(os.Getenv("LEGO_EXPERIMENTAL_DNS_TCP_ONLY")); ok {
tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout}
r, _, err := tcp.Exchange(m, ns)
if err != nil {
return r, &DNSError{Message: "DNS call error", MsgIn: m, NS: ns, Err: err}

View file

@ -5,138 +5,237 @@ import (
"sort"
"testing"
"github.com/go-acme/lego/v4/platform/tester/dnsmock"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupNameserversOK(t *testing.T) {
func Test_lookupNameserversOK(t *testing.T) {
testCases := []struct {
fqdn string
nss []string
desc string
fakeDNSServer *dnsmock.Builder
fqdn string
expected []string
}{
{
fqdn: "en.wikipedia.org.",
nss: []string{"ns0.wikimedia.org.", "ns1.wikimedia.org.", "ns2.wikimedia.org."},
fqdn: "en.wikipedia.org.localhost.",
fakeDNSServer: dnsmock.NewServer().
Query("en.wikipedia.org.localhost SOA", dnsmock.CNAME("dyna.wikimedia.org.localhost")).
Query("wikipedia.org.localhost SOA", dnsmock.SOA("")).
Query("wikipedia.org.localhost NS",
dnsmock.Answer(
fakeNS("wikipedia.org.localhost.", "ns0.wikimedia.org.localhost."),
fakeNS("wikipedia.org.localhost.", "ns1.wikimedia.org.localhost."),
fakeNS("wikipedia.org.localhost.", "ns2.wikimedia.org.localhost."),
),
),
expected: []string{"ns0.wikimedia.org.localhost.", "ns1.wikimedia.org.localhost.", "ns2.wikimedia.org.localhost."},
},
{
fqdn: "www.google.com.",
nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
fqdn: "www.google.com.localhost.",
fakeDNSServer: dnsmock.NewServer().
Query("www.google.com.localhost. SOA", dnsmock.Noop).
Query("google.com.localhost. SOA", dnsmock.SOA("")).
Query("google.com.localhost. NS",
dnsmock.Answer(
fakeNS("google.com.localhost.", "ns1.google.com.localhost."),
fakeNS("google.com.localhost.", "ns2.google.com.localhost."),
fakeNS("google.com.localhost.", "ns3.google.com.localhost."),
fakeNS("google.com.localhost.", "ns4.google.com.localhost."),
),
),
expected: []string{"ns1.google.com.localhost.", "ns2.google.com.localhost.", "ns3.google.com.localhost.", "ns4.google.com.localhost."},
},
{
fqdn: "physics.georgetown.edu.",
nss: []string{"ns4.georgetown.edu.", "ns5.georgetown.edu.", "ns6.georgetown.edu."},
fqdn: "mail.proton.me.localhost.",
fakeDNSServer: dnsmock.NewServer().
Query("mail.proton.me.localhost. SOA", dnsmock.Noop).
Query("proton.me.localhost. SOA", dnsmock.SOA("")).
Query("proton.me.localhost. NS",
dnsmock.Answer(
fakeNS("proton.me.localhost.", "ns1.proton.me.localhost."),
fakeNS("proton.me.localhost.", "ns2.proton.me.localhost."),
fakeNS("proton.me.localhost.", "ns3.proton.me.localhost."),
),
),
expected: []string{"ns1.proton.me.localhost.", "ns2.proton.me.localhost.", "ns3.proton.me.localhost."},
},
}
for _, test := range testCases {
t.Run(test.fqdn, func(t *testing.T) {
t.Parallel()
useAsNameserver(t, test.fakeDNSServer.Build(t))
nss, err := lookupNameservers(test.fqdn)
require.NoError(t, err)
sort.Strings(nss)
sort.Strings(test.nss)
sort.Strings(test.expected)
assert.EqualValues(t, test.nss, nss)
assert.Equal(t, test.expected, nss)
})
}
}
func TestLookupNameserversErr(t *testing.T) {
func Test_lookupNameserversErr(t *testing.T) {
testCases := []struct {
desc string
fqdn string
error string
desc string
fqdn string
fakeDNSServer *dnsmock.Builder
error string
}{
{
desc: "invalid tld",
fqdn: "_null.n0n0.",
error: "could not find zone",
desc: "NXDOMAIN",
fqdn: "example.invalid.",
fakeDNSServer: dnsmock.NewServer().
Query(". SOA", dnsmock.Error(dns.RcodeNameError)),
error: "could not find zone: [fqdn=example.invalid.] could not find the start of authority for 'example.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]",
},
{
desc: "NS error",
fqdn: "example.com.",
fakeDNSServer: dnsmock.NewServer().
Query("example.com. SOA", dnsmock.SOA("")).
Query("example.com. NS", dnsmock.Error(dns.RcodeServerFailure)),
error: "[zone=example.com.] could not determine authoritative nameservers",
},
{
desc: "empty NS",
fqdn: "example.com.",
fakeDNSServer: dnsmock.NewServer().
Query("example.com. SOA", dnsmock.SOA("")).
Query("example.me NS", dnsmock.Noop),
error: "[zone=example.com.] could not determine authoritative nameservers",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
useAsNameserver(t, test.fakeDNSServer.Build(t))
_, err := lookupNameservers(test.fqdn)
require.Error(t, err)
assert.Contains(t, err.Error(), test.error)
assert.EqualError(t, err, test.error)
})
}
}
var findXByFqdnTestCases = []struct {
type lookupSoaByFqdnTestCase struct {
desc string
fqdn string
zone string
primaryNs string
nameservers []string
expectedError string
}{
{
desc: "domain is a CNAME",
fqdn: "mail.google.com.",
zone: "google.com.",
primaryNs: "ns1.google.com.",
nameservers: recursiveNameservers,
},
{
desc: "domain is a non-existent subdomain",
fqdn: "foo.google.com.",
zone: "google.com.",
primaryNs: "ns1.google.com.",
nameservers: recursiveNameservers,
},
{
desc: "domain is a eTLD",
fqdn: "example.com.ac.",
zone: "ac.",
primaryNs: "a0.nic.ac.",
nameservers: recursiveNameservers,
},
{
desc: "domain is a cross-zone CNAME",
fqdn: "cross-zone-example.assets.sh.",
zone: "assets.sh.",
primaryNs: "gina.ns.cloudflare.com.",
nameservers: recursiveNameservers,
},
{
desc: "NXDOMAIN",
fqdn: "test.lego.zz.",
zone: "lego.zz.",
nameservers: []string{"8.8.8.8:53"},
expectedError: "[fqdn=test.lego.zz.] could not find the start of authority for 'test.lego.zz.' [question='zz. IN SOA', code=NXDOMAIN]",
},
{
desc: "several non existent nameservers",
fqdn: "mail.google.com.",
zone: "google.com.",
primaryNs: "ns1.google.com.",
nameservers: []string{":7053", ":8053", "8.8.8.8:53"},
},
{
desc: "only non-existent nameservers",
fqdn: "mail.google.com.",
zone: "google.com.",
nameservers: []string{":7053", ":8053", ":9053"},
// use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053.
expectedError: "[fqdn=mail.google.com.] could not find the start of authority for 'mail.google.com.': DNS call error: read udp ",
},
{
desc: "no nameservers",
fqdn: "test.ldez.com.",
zone: "ldez.com.",
nameservers: []string{},
expectedError: "[fqdn=test.ldez.com.] could not find the start of authority for 'test.ldez.com.': empty list of nameservers",
},
}
func lookupSoaByFqdnTestCases(t *testing.T) []lookupSoaByFqdnTestCase {
t.Helper()
return []lookupSoaByFqdnTestCase{
{
desc: "domain is a CNAME",
fqdn: "mail.example.com.",
zone: "example.com.",
primaryNs: "ns1.example.com.",
nameservers: []string{
dnsmock.NewServer().
Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")).
Query("example.com. SOA", dnsmock.SOA("")).
Build(t).
String(),
},
},
{
desc: "domain is a non-existent subdomain",
fqdn: "foo.example.com.",
zone: "example.com.",
primaryNs: "ns1.example.com.",
nameservers: []string{
dnsmock.NewServer().
Query("foo.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("example.com. SOA", dnsmock.SOA("")).
Build(t).
String(),
},
},
{
desc: "domain is a eTLD",
fqdn: "example.com.ac.",
zone: "ac.",
primaryNs: "ns1.nic.ac.",
nameservers: []string{
dnsmock.NewServer().
Query("example.com.ac. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("com.ac. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("ac. SOA", dnsmock.SOA("")).
Build(t).
String(),
},
},
{
desc: "domain is a cross-zone CNAME",
fqdn: "cross-zone-example.example.com.",
zone: "example.com.",
primaryNs: "ns1.example.com.",
nameservers: []string{
dnsmock.NewServer().
Query("cross-zone-example.example.com. SOA", dnsmock.CNAME("example.org.")).
Query("example.com. SOA", dnsmock.SOA("")).
Build(t).
String(),
},
},
{
desc: "NXDOMAIN",
fqdn: "test.lego.invalid.",
zone: "lego.invalid.",
nameservers: []string{
dnsmock.NewServer().
Query("test.lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
Build(t).
String(),
},
expectedError: `[fqdn=test.lego.invalid.] could not find the start of authority for 'test.lego.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]`,
},
{
desc: "several non existent nameservers",
fqdn: "mail.example.com.",
zone: "example.com.",
primaryNs: "ns1.example.com.",
nameservers: []string{
":7053",
":8053",
dnsmock.NewServer().
Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")).
Query("example.com. SOA", dnsmock.SOA("")).
Build(t).
String(),
},
},
{
desc: "only non-existent nameservers",
fqdn: "mail.example.com.",
zone: "example.com.",
nameservers: []string{":7053", ":8053", ":9053"},
// use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053.
expectedError: "[fqdn=mail.example.com.] could not find the start of authority for 'mail.example.com.': DNS call error: read udp ",
},
{
desc: "no nameservers",
fqdn: "test.example.com.",
zone: "example.com.",
nameservers: []string{},
expectedError: "[fqdn=test.example.com.] could not find the start of authority for 'test.example.com.': empty list of nameservers",
},
}
}
func TestFindZoneByFqdnCustom(t *testing.T) {
for _, test := range findXByFqdnTestCases {
for _, test := range lookupSoaByFqdnTestCases(t) {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
@ -153,7 +252,7 @@ func TestFindZoneByFqdnCustom(t *testing.T) {
}
func TestFindPrimaryNsByFqdnCustom(t *testing.T) {
for _, test := range findXByFqdnTestCases {
for _, test := range lookupSoaByFqdnTestCases(t) {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
@ -169,7 +268,7 @@ func TestFindPrimaryNsByFqdnCustom(t *testing.T) {
}
}
func TestResolveConfServers(t *testing.T) {
func Test_getNameservers_ResolveConfServers(t *testing.T) {
testCases := []struct {
fixture string
expected []string

View file

@ -9,6 +9,10 @@ import (
"github.com/miekg/dns"
)
// defaultNameserverPort used by authoritative NS.
// This is for tests only.
var defaultNameserverPort = "53"
// PreCheckFunc checks DNS propagation before notifying ACME that the DNS challenge is ready.
type PreCheckFunc func(fqdn, value string) (bool, error)
@ -25,6 +29,7 @@ func WrapPreCheck(wrap WrapPreCheckFunc) ChallengeOption {
}
// DisableCompletePropagationRequirement obsolete.
//
// Deprecated: use DisableAuthoritativeNssPropagationRequirement instead.
func DisableCompletePropagationRequirement() ChallengeOption {
return DisableAuthoritativeNssPropagationRequirement()
@ -121,7 +126,7 @@ func (p preCheck) checkDNSPropagation(fqdn, value string) (bool, error) {
func checkNameserversPropagation(fqdn, value string, nameservers []string, addPort bool) (bool, error) {
for _, ns := range nameservers {
if addPort {
ns = net.JoinHostPort(ns, "53")
ns = net.JoinHostPort(ns, defaultNameserverPort)
}
r, err := dnsQuery(fqdn, dns.TypeTXT, []string{ns}, false)
@ -136,9 +141,11 @@ func checkNameserversPropagation(fqdn, value string, nameservers []string, addPo
var records []string
var found bool
for _, rr := range r.Answer {
if txt, ok := rr.(*dns.TXT); ok {
record := strings.Join(txt.Txt, "")
records = append(records, record)
if record == value {
found = true

View file

@ -3,40 +3,73 @@ package dns01
import (
"testing"
"github.com/go-acme/lego/v4/platform/tester/dnsmock"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckDNSPropagation(t *testing.T) {
func Test_preCheck_checkDNSPropagation(t *testing.T) {
mockResolver(t,
dnsmock.NewServer().
Query("ns0.lego.localhost. A",
dnsmock.Answer(fakeA("ns0.lego.localhost.", "127.0.0.1"))).
Query("ns1.lego.localhost. A",
dnsmock.Answer(fakeA("ns1.lego.localhost.", "127.0.0.1"))).
Query("example.com. TXT",
dnsmock.Answer(
fakeTXT("example.com.", "one"),
fakeTXT("example.com.", "two"),
fakeTXT("example.com.", "three"),
fakeTXT("example.com.", "four"),
fakeTXT("example.com.", "five"),
),
).
Build(t),
)
useAsNameserver(t,
dnsmock.NewServer().
Query("acme-staging.api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
Query("example.com. SOA", dnsmock.SOA("")).
Query("example.com. NS",
dnsmock.Answer(
fakeNS("example.com.", "ns0.lego.localhost."),
fakeNS("example.com.", "ns1.lego.localhost."),
),
).
Build(t),
)
testCases := []struct {
desc string
fqdn string
value string
expectError bool
desc string
fqdn string
value string
expectedError string
}{
{
desc: "success",
fqdn: "postman-echo.com.",
value: "postman-domain-verification=c85de626cb79d941310696e06558e2e790223802f3697dfbdcaf65510152d52c",
fqdn: "example.com.",
value: "four",
},
{
desc: "no TXT record",
fqdn: "acme-staging.api.letsencrypt.org.",
value: "fe01=",
expectError: true,
desc: "no matching TXT record",
fqdn: "acme-staging.api.example.com.",
value: "fe01=",
expectedError: "did not return the expected TXT record [fqdn: acme-staging.api.example.com., value: fe01=]: one ,two ,three ,four ,five",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ClearFqdnCache()
check := newPreCheck()
ok, err := check.checkDNSPropagation(test.fqdn, test.value)
if test.expectError {
assert.Errorf(t, err, "PreCheckDNS must fail for %s", test.fqdn)
if test.expectedError != "" {
assert.ErrorContainsf(t, err, test.expectedError, "PreCheckDNS must fail for %s", test.fqdn)
assert.False(t, ok, "PreCheckDNS must fail for %s", test.fqdn)
} else {
assert.NoErrorf(t, err, "PreCheckDNS failed for %s", test.fqdn)
@ -46,69 +79,67 @@ func TestCheckDNSPropagation(t *testing.T) {
}
}
func TestCheckAuthoritativeNss(t *testing.T) {
func Test_checkNameserversPropagation_authoritativeNss(t *testing.T) {
testCases := []struct {
desc string
fqdn, value string
ns []string
expected bool
desc string
fqdn, value string
fakeDNSServer *dnsmock.Builder
expectedError string
}{
{
desc: "TXT RR w/ expected value",
fqdn: "8.8.8.8.asn.routeviews.org.",
value: "151698.8.8.024",
ns: []string{"asnums.routeviews.org."},
expected: true,
desc: "TXT RR w/ expected value",
// NS: asnums.routeviews.org.
fqdn: "8.8.8.8.asn.routeviews.org.",
value: "151698.8.8.024",
fakeDNSServer: dnsmock.NewServer().
Query("8.8.8.8.asn.routeviews.org. TXT",
dnsmock.Answer(
fakeTXT("8.8.8.8.asn.routeviews.org.", "151698.8.8.024"),
),
),
},
{
desc: "TXT RR w/ unexpected value",
// NS: asnums.routeviews.org.
fqdn: "8.8.8.8.asn.routeviews.org.",
value: "fe01=",
fakeDNSServer: dnsmock.NewServer().
Query("8.8.8.8.asn.routeviews.org. TXT",
dnsmock.Answer(
fakeTXT("8.8.8.8.asn.routeviews.org.", "15169"),
fakeTXT("8.8.8.8.asn.routeviews.org.", "8.8.8.0"),
fakeTXT("8.8.8.8.asn.routeviews.org.", "24"),
),
),
expectedError: "did not return the expected TXT record [fqdn: 8.8.8.8.asn.routeviews.org., value: fe01=]: 15169 ,8.8.8.0 ,24",
},
{
desc: "No TXT RR",
fqdn: "ns1.google.com.",
ns: []string{"ns2.google.com."},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ClearFqdnCache()
ok, _ := checkNameserversPropagation(test.fqdn, test.value, test.ns, true)
assert.Equal(t, test.expected, ok, test.fqdn)
})
}
}
func TestCheckAuthoritativeNssErr(t *testing.T) {
testCases := []struct {
desc string
fqdn, value string
ns []string
error string
}{
{
desc: "TXT RR /w unexpected value",
fqdn: "8.8.8.8.asn.routeviews.org.",
value: "fe01=",
ns: []string{"asnums.routeviews.org."},
error: "did not return the expected TXT record",
},
{
desc: "No TXT RR",
// NS: ns2.google.com.
fqdn: "ns1.google.com.",
value: "fe01=",
ns: []string{"ns2.google.com."},
error: "did not return the expected TXT record",
fakeDNSServer: dnsmock.NewServer().
Query("ns1.google.com.", dnsmock.Noop),
expectedError: "did not return the expected TXT record [fqdn: ns1.google.com., value: fe01=]: ",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ClearFqdnCache()
_, err := checkNameserversPropagation(test.fqdn, test.value, test.ns, true)
require.Error(t, err)
assert.Contains(t, err.Error(), test.error)
addr := test.fakeDNSServer.Build(t)
ok, err := checkNameserversPropagation(test.fqdn, test.value, []string{addr.String()}, false)
if test.expectedError == "" {
require.NoError(t, err)
assert.True(t, ok)
} else {
require.Error(t, err)
require.ErrorContains(t, err, test.expectedError)
assert.False(t, ok)
}
})
}
}

View file

@ -3,6 +3,7 @@ package http01
import (
"fmt"
"net/http"
"net/netip"
"strings"
)
@ -54,7 +55,7 @@ func (m *hostMatcher) name() string {
}
func (m *hostMatcher) matches(r *http.Request, domain string) bool {
return strings.HasPrefix(r.Host, domain)
return matchDomain(r.Host, domain)
}
// arbitraryMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name.
@ -65,7 +66,7 @@ func (m arbitraryMatcher) name() string {
}
func (m arbitraryMatcher) matches(r *http.Request, domain string) bool {
return strings.HasPrefix(r.Header.Get(m.name()), domain)
return matchDomain(r.Header.Get(m.name()), domain)
}
// forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name.
@ -87,7 +88,8 @@ func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
}
host := fwds[0]["host"]
return strings.HasPrefix(host, domain)
return matchDomain(host, domain)
}
// parsing requires some form of state machine.
@ -98,6 +100,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
inquote := false
pos := 0
l := len(s)
for i := 0; i < l; i++ {
r := rune(s[i])
@ -109,6 +112,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
pos = i
inquote = false
}
continue
}
@ -117,6 +121,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
if key == "" {
return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
}
inquote = true
pos = i + 1
@ -133,11 +138,10 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
case r == ',': // end of forwarded-element
if key != "" {
if val == "" {
val = s[pos:i]
}
val = s[pos:i]
cur[key] = val
}
elements = append(elements, cur)
cur = make(map[string]string)
key = ""
@ -160,11 +164,14 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
if pos < len(s) {
val = s[pos:]
}
cur[key] = val
}
if len(cur) > 0 {
elements = append(elements, cur)
}
return elements, nil
}
@ -179,9 +186,19 @@ func skipWS(s string, i int) int {
for isWS(rune(s[i+1])) {
i++
}
return i
}
func isWS(r rune) bool {
return strings.ContainsRune(" \t\v\r\n", r)
}
func matchDomain(src, domain string) bool {
addr, err := netip.ParseAddr(domain)
if err == nil && addr.Is6() {
domain = "[" + domain + "]"
}
return strings.HasPrefix(src, domain)
}

View file

@ -1,13 +1,15 @@
package http01
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseForwardedHeader(t *testing.T) {
func Test_parseForwardedHeader(t *testing.T) {
testCases := []struct {
name string
input string
@ -75,7 +77,7 @@ func TestParseForwardedHeader(t *testing.T) {
actual, err := parseForwardedHeader(test.input)
if test.err == "" {
require.NoError(t, err)
assert.EqualValues(t, test.want, actual)
assert.Equal(t, test.want, actual)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), test.err)
@ -83,3 +85,54 @@ func TestParseForwardedHeader(t *testing.T) {
})
}
}
func Test_hostMatcher_matches(t *testing.T) {
hm := &hostMatcher{}
testCases := []struct {
desc string
domain string
req *http.Request
expected assert.BoolAssertionFunc
}{
{
desc: "exact domain",
domain: "example.com",
req: httptest.NewRequest(http.MethodGet, "http://example.com", nil),
expected: assert.True,
},
{
desc: "request with path",
domain: "example.com",
req: httptest.NewRequest(http.MethodGet, "http://example.com/foo/bar", nil),
expected: assert.True,
},
{
desc: "ipv4",
domain: "127.0.0.1",
req: httptest.NewRequest(http.MethodGet, "http://127.0.0.1", nil),
expected: assert.True,
},
{
desc: "ipv6",
domain: "2001:db8::1",
req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil),
expected: assert.True,
},
{
desc: "ipv6 with brackets",
domain: "[2001:db8::1]",
req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil),
expected: assert.True,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
hm.matches(test.req, test.domain)
test.expected(t, hm.matches(test.req, test.domain))
})
}
}

View file

@ -2,6 +2,7 @@ package http01
import (
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
@ -11,6 +12,16 @@ import (
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
type ChallengeOption func(*Challenge) error
// SetDelay sets a delay between the start of the HTTP server and the challenge validation.
func SetDelay(delay time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
chlg.delay = delay
return nil
}
}
// ChallengePath returns the URL path for the `http-01` challenge.
func ChallengePath(token string) string {
return "/.well-known/acme-challenge/" + token
@ -20,14 +31,24 @@ type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
delay time.Duration
}
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
return &Challenge{
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge {
chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
}
for _, opt := range opts {
err := opt(chlg)
if err != nil {
log.Infof("challenge option error: %v", err)
}
}
return chlg
}
func (c *Challenge) SetProvider(provider challenge.Provider) {
@ -53,6 +74,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err)
}
defer func() {
err := c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth)
if err != nil {
@ -60,6 +82,11 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
}()
if c.delay > 0 {
time.Sleep(c.delay)
}
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}

View file

@ -44,6 +44,7 @@ func NewUnixProviderServer(socketPath string, mode fs.FileMode) *ProviderServer
// Present starts a web server and makes the token available at `ChallengePath(token)` for web requests.
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
var err error
s.listener, err = net.Listen(s.network, s.GetAddress())
if err != nil {
return fmt.Errorf("could not start HTTP server for challenge: %w", err)
@ -56,7 +57,9 @@ func (s *ProviderServer) Present(domain, token, keyAuth string) error {
}
s.done = make(chan bool)
go s.serve(domain, token, keyAuth)
return nil
}
@ -69,8 +72,11 @@ func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}
s.listener.Close()
<-s.done
return nil
}
@ -107,19 +113,24 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && s.matcher.matches(r, domain) {
w.Header().Set("Content-Type", "text/plain")
_, err := w.Write([]byte(keyAuth))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Infof("[%s] Served key authentication", domain)
} else {
log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure you are passing the %s header properly.", r.Host, r.Method, s.matcher.name())
_, err := w.Write([]byte("TEST"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure you are passing the %s header properly.", r.Host, r.Method, s.matcher.name())
_, err := w.Write([]byte("TEST"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
@ -133,5 +144,6 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) {
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
log.Println(err)
}
s.done <- true
}

View file

@ -67,7 +67,7 @@ func TestProviderServer_GetAddress(t *testing.T) {
}
func TestChallenge(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
providerServer := NewProviderServer("", "23457")
@ -88,6 +88,7 @@ func TestChallenge(t *testing.T) {
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@ -97,10 +98,10 @@ func TestChallenge(t *testing.T) {
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
@ -123,7 +124,7 @@ func TestChallengeUnix(t *testing.T) {
t.Skip("only for UNIX systems")
}
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
dir := t.TempDir()
t.Cleanup(func() { _ = os.RemoveAll(dir) })
@ -157,6 +158,7 @@ func TestChallengeUnix(t *testing.T) {
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@ -166,10 +168,10 @@ func TestChallengeUnix(t *testing.T) {
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
@ -188,12 +190,12 @@ func TestChallengeUnix(t *testing.T) {
}
func TestChallengeInvalidPort(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 128)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }
@ -224,6 +226,7 @@ func (h *testProxyHeader) update(r *http.Request) {
if h == nil || len(h.values) == 0 {
return
}
if h.name == "Host" {
r.Host = h.values[0]
} else if h.name != "" {
@ -371,7 +374,7 @@ func TestChallengeWithProxy(t *testing.T) {
func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) {
t.Helper()
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
providerServer := NewProviderServer("localhost", "23457")
if header != nil {
@ -385,6 +388,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
if err != nil {
return err
}
header.update(req)
extra.update(req)
@ -402,6 +406,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@ -411,10 +416,10 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)

View file

@ -3,6 +3,8 @@ package resolver
import (
"bytes"
"fmt"
"maps"
"slices"
"sort"
)
@ -16,10 +18,16 @@ func (e obtainError) Error() string {
for domain := range e {
domains = append(domains, domain)
}
sort.Strings(domains)
for _, domain := range domains {
_, _ = fmt.Fprintf(buffer, "[%s] %s\n", domain, e[domain])
}
return buffer.String()
}
func (e obtainError) Unwrap() []error {
return slices.AppendSeq(make([]error, 0, len(e)), maps.Values(e))
}

View file

@ -0,0 +1,70 @@
package resolver
import (
"errors"
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_obtainError_Error(t *testing.T) {
err := obtainError{
"a": &acme.ProblemDetails{Type: "001"},
"b": errors.New("oops"),
"c": errors.New("I did it again"),
}
require.EqualError(t, err, `error: one or more domains had a problem:
[a] acme: error: 0 :: 001 ::
[b] oops
[c] I did it again
`)
}
func Test_obtainError_Unwrap(t *testing.T) {
testCases := []struct {
desc string
err obtainError
assert assert.BoolAssertionFunc
}{
{
desc: "one ok",
err: obtainError{
"a": &acme.ProblemDetails{},
"b": errors.New("oops"),
"c": errors.New("I did it again"),
},
assert: assert.True,
},
{
desc: "all ok",
err: obtainError{
"a": &acme.ProblemDetails{Type: "001"},
"b": &acme.ProblemDetails{Type: "002"},
"c": &acme.ProblemDetails{Type: "002"},
},
assert: assert.True,
},
{
desc: "nope",
err: obtainError{
"a": errors.New("hello"),
"b": errors.New("oops"),
"c": errors.New("I did it again"),
},
assert: assert.False,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
var pd *acme.ProblemDetails
test.assert(t, errors.As(test.err, &pd))
})
}
}

View file

@ -50,11 +50,14 @@ func NewProber(solverManager *SolverManager) *Prober {
func (p *Prober) Solve(authorizations []acme.Authorization) error {
failures := make(obtainError)
var authSolvers []*selectedAuthSolver
var authSolversSequential []*selectedAuthSolver
var (
authSolvers []*selectedAuthSolver
authSolversSequential []*selectedAuthSolver
)
// Loop through the resources, basically through the domains.
// First pass just selects a solver for each authz.
for _, authz := range authorizations {
domain := challenge.GetTargetedDomain(authz)
if authz.Status == acme.StatusValid {
@ -90,47 +93,88 @@ func (p *Prober) Solve(authorizations []acme.Authorization) error {
if len(failures) > 0 {
return failures
}
return nil
}
func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
// Some CA are using the same token,
// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
// In the sequential mode, this is not a problem because we can solve the challenges in order.
// But it can reduce the number of call the DNS provider APIs.
uniq := make(map[string]struct{})
for i, authSolver := range authSolvers {
// Submit the challenge
domain := challenge.GetTargetedDomain(authSolver.authz)
chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
if solvr, ok := authSolver.solver.(preSolver); ok {
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" {
log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
continue
}
err := solvr.PreSolve(authSolver.authz)
if err != nil {
failures[domain] = err
cleanUp(authSolver.solver, authSolver.authz)
continue
}
uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{}
}
// Solve challenge
err := authSolver.solver.Solve(authSolver.authz)
if err != nil {
failures[domain] = err
cleanUp(authSolver.solver, authSolver.authz)
continue
}
// Clean challenge
cleanUp(authSolver.solver, authSolver.authz)
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" {
// Clean challenge
cleanUp(authSolver.solver, authSolver.authz)
if len(authSolvers)-1 > i {
solvr := authSolver.solver.(sequential)
_, interval := solvr.Sequential()
log.Infof("sequence: wait for %s", interval)
time.Sleep(interval)
if len(authSolvers)-1 > i {
solvr := authSolver.solver.(sequential)
_, interval := solvr.Sequential()
log.Infof("sequence: wait for %s", interval)
time.Sleep(interval)
}
delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
} else {
log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
}
}
}
func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
// Some CA are using the same token,
// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
uniq := make(map[string]struct{})
// For all valid preSolvers, first submit the challenges, so they have max time to propagate
for _, authSolver := range authSolvers {
authz := authSolver.authz
chlg, err := challenge.FindChallenge(challenge.DNS01, authz)
if err == nil {
if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok {
log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
continue
}
uniq[authz.Identifier.Value+chlg.Token] = struct{}{}
}
if solvr, ok := authSolver.solver.(preSolver); ok {
err := solvr.PreSolve(authz)
if err != nil {
@ -142,6 +186,16 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
defer func() {
// Clean all created TXT records
for _, authSolver := range authSolvers {
chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
if err == nil {
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok {
delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
} else {
log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
continue
}
}
cleanUp(authSolver.solver, authSolver.authz)
}
}()
@ -149,6 +203,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
// Finally solve all challenges for real
for _, authSolver := range authSolvers {
authz := authSolver.authz
domain := challenge.GetTargetedDomain(authz)
if failures[domain] != nil {
// already failed in previous loop
@ -165,6 +220,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
func cleanUp(solvr solver, authz acme.Authorization) {
if solvr, ok := solvr.(cleanup); ok {
domain := challenge.GetTargetedDomain(authz)
err := solvr.CleanUp(authz)
if err != nil {
log.Warnf("[%s] acme: cleaning up failed: %v ", domain, err)

View file

@ -1,6 +1,7 @@
package resolver
import (
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
@ -11,34 +12,68 @@ type preSolverMock struct {
preSolve map[string]error
solve map[string]error
cleanUp map[string]error
preSolveCounter int
solveCounter int
cleanUpCounter int
}
func (s *preSolverMock) PreSolve(authorization acme.Authorization) error {
s.preSolveCounter++
return s.preSolve[authorization.Identifier.Value]
}
func (s *preSolverMock) Solve(authorization acme.Authorization) error {
s.solveCounter++
return s.solve[authorization.Identifier.Value]
}
func (s *preSolverMock) CleanUp(authorization acme.Authorization) error {
s.cleanUpCounter++
return s.cleanUp[authorization.Identifier.Value]
}
func (s *preSolverMock) String() string {
return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter)
}
func createStubAuthorizationHTTP01(domain, status string) acme.Authorization {
return createStubAuthorization(domain, status, false, acme.Challenge{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
})
}
func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization {
var chlgs []acme.Challenge
if wildcard {
chlgs = append(chlgs, acme.Challenge{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
})
}
chlgs = append(chlgs, acme.Challenge{
Type: challenge.DNS01.String(),
Validated: time.Now(),
})
return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...)
}
func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization {
return acme.Authorization{
Status: status,
Expires: time.Now(),
Wildcard: wildcard,
Status: status,
Expires: time.Now(),
Identifier: acme.Identifier{
Type: challenge.HTTP01.String(),
Type: "dns",
Value: domain,
},
Challenges: []acme.Challenge{
{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
Error: nil,
},
},
Challenges: chlgs,
}
}

View file

@ -2,19 +2,22 @@ package resolver
import (
"errors"
"fmt"
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/challenge"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProber_Solve(t *testing.T) {
testCases := []struct {
desc string
solvers map[challenge.Type]solver
authz []acme.Authorization
expectedError string
desc string
solvers map[challenge.Type]solver
authz []acme.Authorization
expectedError string
expectedCounters map[challenge.Type]string
}{
{
desc: "success",
@ -26,9 +29,33 @@ func TestProber_Solve(t *testing.T) {
},
},
authz: []acme.Authorization{
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3",
},
},
{
desc: "DNS-01 deduplicate",
solvers: map[challenge.Type]solver{
challenge.DNS01: &preSolverMock{
preSolve: map[string]error{},
solve: map[string]error{},
cleanUp: map[string]error{},
},
},
authz: []acme.Authorization{
createStubAuthorizationDNS01("a.example", false),
createStubAuthorizationDNS01("a.example", true),
createStubAuthorizationDNS01("b.example", false),
createStubAuthorizationDNS01("b.example", true),
createStubAuthorizationDNS01("c.example", true),
createStubAuthorizationDNS01("d.example", false),
},
expectedCounters: map[challenge.Type]string{
challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4",
},
},
{
@ -41,9 +68,12 @@ func TestProber_Solve(t *testing.T) {
},
},
authz: []acme.Authorization{
createStubAuthorizationHTTP01("acme.wtf", acme.StatusValid),
createStubAuthorizationHTTP01("lego.wtf", acme.StatusValid),
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusValid),
createStubAuthorizationHTTP01("example.com", acme.StatusValid),
createStubAuthorizationHTTP01("example.org", acme.StatusValid),
createStubAuthorizationHTTP01("example.net", acme.StatusValid),
},
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0",
},
},
{
@ -51,50 +81,56 @@ func TestProber_Solve(t *testing.T) {
solvers: map[challenge.Type]solver{
challenge.HTTP01: &preSolverMock{
preSolve: map[string]error{
"acme.wtf": errors.New("preSolve error acme.wtf"),
"example.com": errors.New("preSolve error example.com"),
},
solve: map[string]error{
"acme.wtf": errors.New("solve error acme.wtf"),
"example.com": errors.New("solve error example.com"),
},
cleanUp: map[string]error{
"acme.wtf": errors.New("clean error acme.wtf"),
"example.com": errors.New("clean error example.com"),
},
},
},
authz: []acme.Authorization{
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedError: `error: one or more domains had a problem:
[acme.wtf] preSolve error acme.wtf
[example.com] preSolve error example.com
`,
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
},
},
{
desc: "errors at different stages",
solvers: map[challenge.Type]solver{
challenge.HTTP01: &preSolverMock{
preSolve: map[string]error{
"acme.wtf": errors.New("preSolve error acme.wtf"),
"example.com": errors.New("preSolve error example.com"),
},
solve: map[string]error{
"acme.wtf": errors.New("solve error acme.wtf"),
"lego.wtf": errors.New("solve error lego.wtf"),
"example.com": errors.New("solve error example.com"),
"example.org": errors.New("solve error example.org"),
},
cleanUp: map[string]error{
"mydomain.wtf": errors.New("clean error mydomain.wtf"),
"example.net": errors.New("clean error example.net"),
},
},
},
authz: []acme.Authorization{
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedError: `error: one or more domains had a problem:
[acme.wtf] preSolve error acme.wtf
[lego.wtf] solve error lego.wtf
[example.com] preSolve error example.com
[example.org] solve error example.org
`,
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
},
},
}
@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) {
} else {
require.NoError(t, err)
}
for n, s := range test.solvers {
assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s))
}
})
}
}

View file

@ -1,13 +1,13 @@
package resolver
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
@ -15,6 +15,7 @@ import (
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/wait"
)
type byType []acme.Challenge
@ -36,14 +37,14 @@ func NewSolversManager(core *api.Core) *SolverManager {
}
// SetHTTP01Provider specifies a custom provider p that can solve the given HTTP-01 challenge.
func (c *SolverManager) SetHTTP01Provider(p challenge.Provider) error {
c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p)
func (c *SolverManager) SetHTTP01Provider(p challenge.Provider, opts ...http01.ChallengeOption) error {
c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p, opts...)
return nil
}
// SetTLSALPN01Provider specifies a custom provider p that can solve the given TLS-ALPN-01 challenge.
func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider) error {
c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p)
func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider, opts ...tlsalpn01.ChallengeOption) error {
c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p, opts...)
return nil
}
@ -69,6 +70,7 @@ func (c *SolverManager) chooseSolver(authz acme.Authorization) solver {
log.Infof("[%s] acme: use %s solver", domain, chlg.Type)
return solvr
}
log.Infof("[%s] acme: Could not find solver for: %s", domain, chlg.Type)
}
@ -91,20 +93,20 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error {
return nil
}
ra, err := strconv.Atoi(chlng.RetryAfter)
if err != nil {
retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter)
if err != nil || retryAfter == 0 {
// The ACME server MUST return a Retry-After.
// If it doesn't, we'll just poll hard.
// If it doesn't, or if it's invalid, we'll just poll hard.
// Boulder does not implement the ability to retry challenges or the Retry-After header.
// https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82
ra = 5
retryAfter = 5 * time.Second
}
initialInterval := time.Duration(ra) * time.Second
ctx := context.Background()
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = initialInterval
bo.MaxInterval = 10 * initialInterval
bo.MaxElapsedTime = 100 * initialInterval
bo.InitialInterval = retryAfter
bo.MaxInterval = 10 * retryAfter
// After the path is sent, the ACME server will access our server.
// Repeatedly check the server for an updated status on our request.
@ -124,10 +126,12 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error {
return nil
}
return errors.New("the server didn't respond to our request")
return fmt.Errorf("the server didn't respond to our request (status=%s)", authz.Status)
}
return backoff.Retry(operation, bo)
return wait.Retry(ctx, operation,
backoff.WithBackOff(bo),
backoff.WithMaxElapsedTime(100*retryAfter))
}
func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
@ -137,9 +141,9 @@ func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
case acme.StatusPending, acme.StatusProcessing:
return false, nil
case acme.StatusInvalid:
return false, chlng.Error
return false, fmt.Errorf("invalid challenge: %w", chlng.Err())
default:
return false, errors.New("the server returned an unexpected state")
return false, fmt.Errorf("the server returned an unexpected challenge status: %s", chlng.Status)
}
}
@ -154,11 +158,12 @@ func checkAuthorizationStatus(authz acme.Authorization) (bool, error) {
case acme.StatusInvalid:
for _, chlg := range authz.Challenges {
if chlg.Status == acme.StatusInvalid && chlg.Error != nil {
return false, chlg.Error
return false, fmt.Errorf("invalid authorization: %w", chlg.Err())
}
}
return false, fmt.Errorf("the authorization state %s", authz.Status)
return false, errors.New("invalid authorization")
default:
return false, errors.New("the server returned an unexpected state")
return false, fmt.Errorf("the server returned an unexpected authorization status: %s", authz.Status)
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -32,70 +33,50 @@ func TestByType(t *testing.T) {
}
func TestValidate(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
var statuses []string
privateKey, _ := rsa.GenerateKey(rand.Reader, 512)
privateKey, _ := rsa.GenerateKey(rand.Reader, 1024)
mux.HandleFunc("/chlg", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
server := tester.MockACMEServer().
Route("POST /chlg",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if err := validateNoBody(privateKey, req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if err := validateNoBody(privateKey, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rw.Header().Set("Link",
fmt.Sprintf(`<https://%s/my-authz>; rel="up"`, req.Context().Value(http.LocalAddrContextKey)))
w.Header().Set("Link", "<"+apiURL+`/my-authz>; rel="up"`)
st := statuses[0]
statuses = statuses[1:]
st := statuses[0]
statuses = statuses[1:]
chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"}
chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"}
if st == acme.StatusInvalid {
chlg.Error = &acme.ProblemDetails{}
}
servermock.JSONEncode(chlg).ServeHTTP(rw, req)
})).
Route("POST /my-authz",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
st := statuses[0]
statuses = statuses[1:]
err := tester.WriteJSONResponse(w, chlg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
authorization := acme.Authorization{
Status: st,
Challenges: []acme.Challenge{},
}
mux.HandleFunc("/my-authz", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if st == acme.StatusInvalid {
chlg := acme.Challenge{
Status: acme.StatusInvalid,
}
authorization.Challenges = append(authorization.Challenges, chlg)
}
st := statuses[0]
statuses = statuses[1:]
servermock.JSONEncode(authorization).ServeHTTP(rw, req)
})).
BuildHTTPS(t)
authorization := acme.Authorization{
Status: st,
Challenges: []acme.Challenge{},
}
if st == acme.StatusInvalid {
chlg := acme.Challenge{
Status: acme.StatusInvalid,
Error: &acme.ProblemDetails{},
}
authorization.Challenges = append(authorization.Challenges, chlg)
}
err := tester.WriteJSONResponse(w, authorization)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@ -106,7 +87,7 @@ func TestValidate(t *testing.T) {
{
name: "POST-unexpected",
statuses: []string{"weird"},
want: "unexpected",
want: "the server returned an unexpected challenge status: weird",
},
{
name: "POST-valid",
@ -115,12 +96,12 @@ func TestValidate(t *testing.T) {
{
name: "POST-invalid",
statuses: []string{acme.StatusInvalid},
want: "error",
want: "invalid challenge:",
},
{
name: "POST-pending-unexpected",
statuses: []string{acme.StatusPending, "weird"},
want: "unexpected",
want: "the server returned an unexpected authorization status: weird",
},
{
name: "POST-pending-valid",
@ -129,7 +110,7 @@ func TestValidate(t *testing.T) {
{
name: "POST-pending-invalid",
statuses: []string{acme.StatusPending, acme.StatusInvalid},
want: "error",
want: "invalid authorization",
},
}
@ -137,7 +118,7 @@ func TestValidate(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
statuses = test.statuses
err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: apiURL + "/chlg"})
err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: server.URL + "/chlg"})
if test.want == "" {
require.NoError(t, err)
} else {
@ -148,6 +129,126 @@ func TestValidate(t *testing.T) {
}
}
func Test_checkChallengeStatus(t *testing.T) {
testCases := []struct {
desc string
challenge acme.Challenge
requireErr require.ErrorAssertionFunc
expected bool
}{
{
desc: "status valid",
challenge: acme.Challenge{Status: acme.StatusValid},
requireErr: require.NoError,
expected: true,
},
{
desc: "status invalid",
challenge: acme.Challenge{Status: acme.StatusInvalid},
requireErr: require.Error,
expected: false,
},
{
desc: "status invalid with error",
challenge: acme.Challenge{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}},
requireErr: require.Error,
expected: false,
},
{
desc: "status pending",
challenge: acme.Challenge{Status: acme.StatusPending},
requireErr: require.NoError,
expected: false,
},
{
desc: "status processing",
challenge: acme.Challenge{Status: acme.StatusProcessing},
requireErr: require.NoError,
expected: false,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
status, err := checkChallengeStatus(acme.ExtendedChallenge{Challenge: test.challenge})
test.requireErr(t, err)
assert.Equal(t, test.expected, status)
})
}
}
func Test_checkAuthorizationStatus(t *testing.T) {
testCases := []struct {
desc string
authorization acme.Authorization
requireErr require.ErrorAssertionFunc
expected bool
}{
{
desc: "status valid",
authorization: acme.Authorization{Status: acme.StatusValid},
requireErr: require.NoError,
expected: true,
},
{
desc: "status invalid",
authorization: acme.Authorization{Status: acme.StatusInvalid},
requireErr: require.Error,
expected: false,
},
{
desc: "status invalid with error",
authorization: acme.Authorization{Status: acme.StatusInvalid, Challenges: []acme.Challenge{{Error: &acme.ProblemDetails{}}}},
requireErr: require.Error,
expected: false,
},
{
desc: "status pending",
authorization: acme.Authorization{Status: acme.StatusPending},
requireErr: require.NoError,
expected: false,
},
{
desc: "status processing",
authorization: acme.Authorization{Status: acme.StatusProcessing},
requireErr: require.NoError,
expected: false,
},
{
desc: "status deactivated",
authorization: acme.Authorization{Status: acme.StatusDeactivated},
requireErr: require.Error,
expected: false,
},
{
desc: "status expired",
authorization: acme.Authorization{Status: acme.StatusExpired},
requireErr: require.Error,
expected: false,
},
{
desc: "status revoked",
authorization: acme.Authorization{Status: acme.StatusRevoked},
requireErr: require.Error,
expected: false,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
status, err := checkAuthorizationStatus(test.authorization)
test.requireErr(t, err)
assert.Equal(t, test.expected, status)
})
}
}
// validateNoBody reads the http.Request POST body, parses the JWS and validates it to read the body.
// If there is an error doing this,
// or if the JWS body is not the empty JSON payload "{}" or a POST-as-GET payload "" an error is returned.
@ -159,6 +260,7 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error {
}
sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
if err != nil {
return err
@ -175,5 +277,6 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error {
if bodyStr := string(body); bodyStr != "{}" && bodyStr != "" {
return fmt.Errorf(`expected JWS POST body "{}" or "", got %q`, bodyStr)
}
return nil
}

View file

@ -7,6 +7,7 @@ import (
"crypto/x509/pkix"
"encoding/asn1"
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
@ -21,18 +22,38 @@ var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
type ChallengeOption func(*Challenge) error
// SetDelay sets a delay between the start of the TLS listener and the challenge validation.
func SetDelay(delay time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
chlg.delay = delay
return nil
}
}
type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
delay time.Duration
}
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
return &Challenge{
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge {
chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
}
for _, opt := range opts {
err := opt(chlg)
if err != nil {
log.Infof("challenge option error: %v", err)
}
}
return chlg
}
func (c *Challenge) SetProvider(provider challenge.Provider) {
@ -59,6 +80,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", challenge.GetTargetedDomain(authz), err)
}
defer func() {
err := c.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
@ -66,7 +88,12 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
}()
if c.delay > 0 {
time.Sleep(c.delay)
}
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}

View file

@ -8,7 +8,6 @@ import (
"crypto/tls"
"encoding/asn1"
"net"
"net/http"
"testing"
"github.com/go-acme/lego/v4/acme"
@ -21,7 +20,7 @@ import (
)
func TestChallenge(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
domain := "localhost"
port := "24457"
@ -43,6 +42,7 @@ func TestChallenge(t *testing.T) {
assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions")
idx := -1
for i, ext := range remoteCert.Extensions {
if idPeAcmeIdentifierV1.Equal(ext.Id) {
idx = i
@ -66,10 +66,10 @@ func TestChallenge(t *testing.T) {
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(
@ -93,12 +93,12 @@ func TestChallenge(t *testing.T) {
}
func TestChallengeInvalidPort(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 128)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(
@ -123,7 +123,7 @@ func TestChallengeInvalidPort(t *testing.T) {
}
func TestChallengeIPaddress(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
server := tester.MockACMEServer().BuildHTTPS(t)
domain := "127.0.0.1"
port := "24457"
@ -146,31 +146,37 @@ func TestChallengeIPaddress(t *testing.T) {
assert.True(t, net.ParseIP("127.0.0.1").Equal(remoteCert.IPAddresses[0]), "challenge certificate IPAddress ")
assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions")
var foundAcmeIdentifier bool
var extValue []byte
var (
foundAcmeIdentifier bool
extValue []byte
)
for _, ext := range remoteCert.Extensions {
if idPeAcmeIdentifierV1.Equal(ext.Id) {
assert.True(t, ext.Critical, "Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical")
foundAcmeIdentifier = true
extValue = ext.Value
break
}
}
require.True(t, foundAcmeIdentifier, "Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id,")
zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization))
value, err := asn1.Marshal(zBytes[:sha256.Size])
require.NoError(t, err, "Expected marshaling of the keyAuth to return no error")
require.EqualValues(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth")
require.Equal(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth")
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(

View file

@ -2,10 +2,8 @@ package cmd
import (
"crypto"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"net/url"
"os"
"path/filepath"
@ -18,6 +16,8 @@ import (
"github.com/urfave/cli/v2"
)
const userIDPlaceholder = "noemail@example.com"
const (
baseAccountsRootFolderName = "accounts"
baseKeysFolderName = "keys"
@ -34,7 +34,7 @@ const (
//
// rootUserPath:
//
// ./.lego/accounts/localhost_14000/hubert@hubert.com/
// ./.lego/accounts/localhost_14000/foo@example.com/
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
// │ └── root accounts directory
@ -42,7 +42,7 @@ const (
//
// keysPath:
//
// ./.lego/accounts/localhost_14000/hubert@hubert.com/keys/
// ./.lego/accounts/localhost_14000/foo@example.com/keys/
// │ │ │ │ └── root keys directory
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
@ -51,7 +51,7 @@ const (
//
// accountFilePath:
//
// ./.lego/accounts/localhost_14000/hubert@hubert.com/account.json
// ./.lego/accounts/localhost_14000/foo@example.com/account.json
// │ │ │ │ └── account file
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
@ -59,6 +59,7 @@ const (
// └── "path" option
type AccountsStorage struct {
userID string
email string
rootPath string
rootUserPath string
keysPath string
@ -68,8 +69,13 @@ type AccountsStorage struct {
// NewAccountsStorage Creates a new AccountsStorage.
func NewAccountsStorage(ctx *cli.Context) *AccountsStorage {
// TODO: move to account struct? Currently MUST pass email.
email := getEmail(ctx)
// TODO: move to account struct?
email := ctx.String(flgEmail)
userID := email
if userID == "" {
userID = userIDPlaceholder
}
serverURL, err := url.Parse(ctx.String(flgServer))
if err != nil {
@ -79,10 +85,11 @@ func NewAccountsStorage(ctx *cli.Context) *AccountsStorage {
rootPath := filepath.Join(ctx.String(flgPath), baseAccountsRootFolderName)
serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host)
accountsPath := filepath.Join(rootPath, serverPath)
rootUserPath := filepath.Join(accountsPath, email)
rootUserPath := filepath.Join(accountsPath, userID)
return &AccountsStorage{
userID: email,
userID: userID,
email: email,
rootPath: rootPath,
rootUserPath: rootUserPath,
keysPath: filepath.Join(rootUserPath, baseKeysFolderName),
@ -98,6 +105,7 @@ func (s *AccountsStorage) ExistsAccountFilePath() bool {
} else if err != nil {
log.Fatal(err)
}
return true
}
@ -113,6 +121,10 @@ func (s *AccountsStorage) GetUserID() string {
return s.userID
}
func (s *AccountsStorage) GetEmail() string {
return s.email
}
func (s *AccountsStorage) Save(account *Account) error {
jsonBytes, err := json.MarshalIndent(account, "", "\t")
if err != nil {
@ -125,13 +137,14 @@ func (s *AccountsStorage) Save(account *Account) error {
func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
fileBytes, err := os.ReadFile(s.accountFilePath)
if err != nil {
log.Fatalf("Could not load file for account %s: %v", s.userID, err)
log.Fatalf("Could not load file for account %s: %v", s.GetUserID(), err)
}
var account Account
err = json.Unmarshal(fileBytes, &account)
if err != nil {
log.Fatalf("Could not parse file for account %s: %v", s.userID, err)
log.Fatalf("Could not parse file for account %s: %v", s.GetUserID(), err)
}
account.key = privateKey
@ -139,13 +152,14 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
if account.Registration == nil || account.Registration.Body.Status == "" {
reg, err := tryRecoverRegistration(s.ctx, privateKey)
if err != nil {
log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.userID, err)
log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.GetUserID(), err)
}
account.Registration = reg
err = s.Save(&account)
if err != nil {
log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.userID, err)
log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.GetUserID(), err)
}
}
@ -153,18 +167,19 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
}
func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey {
accKeyPath := filepath.Join(s.keysPath, s.userID+".key")
accKeyPath := filepath.Join(s.keysPath, s.GetUserID()+".key")
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
log.Printf("No key found for account %s. Generating a %s key.", s.userID, keyType)
log.Printf("No key found for account %s. Generating a %s key.", s.GetUserID(), keyType)
s.createKeysFolder()
privateKey, err := generatePrivateKey(accKeyPath, keyType)
if err != nil {
log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err)
log.Fatalf("Could not generate RSA private account key for account %s: %v", s.GetUserID(), err)
}
log.Printf("Saved key to %s", accKeyPath)
return privateKey
}
@ -178,7 +193,7 @@ func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.Priva
func (s *AccountsStorage) createKeysFolder() {
if err := createNonExistingFolder(s.keysPath); err != nil {
log.Fatalf("Could not check/create directory for account %s: %v", s.userID, err)
log.Fatalf("Could not check/create directory for account %s: %v", s.GetUserID(), err)
}
}
@ -195,6 +210,7 @@ func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.Private
defer certOut.Close()
pemKey := certcrypto.PEMBlock(privateKey)
err = pem.Encode(certOut, pemKey)
if err != nil {
return nil, err
@ -209,16 +225,12 @@ func loadPrivateKey(file string) (crypto.PrivateKey, error) {
return nil, err
}
keyBlock, _ := pem.Decode(keyBytes)
switch keyBlock.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(keyBlock.Bytes)
privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes)
if err != nil {
return nil, err
}
return nil, errors.New("unknown private key type")
return privateKey, nil
}
func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) {
@ -236,5 +248,6 @@ func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*re
if err != nil {
return nil, err
}
return reg, nil
}

View file

@ -2,7 +2,6 @@ package cmd
import (
"bytes"
"crypto"
"crypto/x509"
"encoding/json"
"encoding/pem"
@ -159,6 +158,7 @@ func (s *CertificatesStorage) ExistsFile(domain, extension string) bool {
} else if err != nil {
log.Fatal(err)
}
return true
}
@ -233,27 +233,9 @@ func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.R
return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err)
}
keyPemBlock, _ := pem.Decode(certRes.PrivateKey)
if keyPemBlock == nil {
return fmt.Errorf("unable to parse PrivateKey for domain %s", domain)
}
var privateKey crypto.Signer
var keyErr error
switch keyPemBlock.Type {
case "RSA PRIVATE KEY":
privateKey, keyErr = x509.ParsePKCS1PrivateKey(keyPemBlock.Bytes)
if keyErr != nil {
return fmt.Errorf("unable to load RSA PrivateKey for domain %s: %w", domain, keyErr)
}
case "EC PRIVATE KEY":
privateKey, keyErr = x509.ParseECPrivateKey(keyPemBlock.Bytes)
if keyErr != nil {
return fmt.Errorf("unable to load EC PrivateKey for domain %s: %w", domain, keyErr)
}
default:
return fmt.Errorf("unsupported PrivateKey type '%s' for domain %s", keyPemBlock.Type, domain)
privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
if err != nil {
return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err)
}
encoder, err := getPFXEncoder(s.pfxFormat)
@ -302,6 +284,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er
}
var certChain []*x509.Certificate
for chainCertPemBlock != nil {
chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes)
if err != nil {
@ -317,6 +300,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er
func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) {
var encoder *pkcs12.Encoder
switch pfxFormat {
case "SHA256":
encoder = pkcs12.Modern2023
@ -337,5 +321,6 @@ func sanitizedDomain(domain string) string {
if err != nil {
log.Fatal(err)
}
return safe
}

View file

@ -58,7 +58,7 @@ type errWriter struct {
err error
}
func (ew *errWriter) writeln(a ...interface{}) {
func (ew *errWriter) writeln(a ...any) {
if ew.err != nil {
return
}
@ -66,7 +66,7 @@ func (ew *errWriter) writeln(a ...interface{}) {
_, ew.err = fmt.Fprintln(ew.w, a...)
}
func (ew *errWriter) writef(format string, a ...interface{}) {
func (ew *errWriter) writef(format string, a ...any) {
if ew.err != nil {
return
}

View file

@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
@ -36,7 +37,7 @@ func createList() *cli.Command {
// fake email, needed by NewAccountsStorage
&cli.StringFlag{
Name: flgEmail,
Value: "unknown",
Value: "",
Hidden: true,
},
},
@ -67,6 +68,7 @@ func listCertificates(ctx *cli.Context) error {
if !names {
fmt.Println("No certificates found.")
}
return nil
}
@ -99,6 +101,11 @@ func listCertificates(ctx *cli.Context) error {
} else {
fmt.Println(" Certificate Name:", name)
fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", "))
if len(pCert.IPAddresses) > 0 {
fmt.Println(" IPs:", formatIPAddresses(pCert.IPAddresses))
}
fmt.Println(" Expiry Date:", pCert.NotAfter)
fmt.Println(" Certificate Path:", filename)
fmt.Println()
@ -122,6 +129,7 @@ func listAccount(ctx *cli.Context) error {
}
fmt.Println("Found the following accounts:")
for _, filename := range matches {
data, err := os.ReadFile(filename)
if err != nil {
@ -129,6 +137,7 @@ func listAccount(ctx *cli.Context) error {
}
var account Account
err = json.Unmarshal(data, &account)
if err != nil {
return err
@ -147,3 +156,12 @@ func listAccount(ctx *cli.Context) error {
return nil
}
func formatIPAddresses(ipAddresses []net.IP) string {
var ips []string
for _, ip := range ipAddresses {
ips = append(ips, ip.String())
}
return strings.Join(ips, ", ")
}

View file

@ -20,22 +20,15 @@ import (
// Flag names.
const (
flgDays = "days"
flgRenewDays = "days"
flgRenewDynamic = "dynamic"
flgARIDisable = "ari-disable"
flgARIWaitToRenewDuration = "ari-wait-to-renew-duration"
flgReuseKey = "reuse-key"
flgRenewHook = "renew-hook"
flgRenewHookTimeout = "renew-hook-timeout"
flgNoRandomSleep = "no-random-sleep"
)
const (
renewEnvAccountEmail = "LEGO_ACCOUNT_EMAIL"
renewEnvCertDomain = "LEGO_CERT_DOMAIN"
renewEnvCertPath = "LEGO_CERT_PATH"
renewEnvCertKeyPath = "LEGO_CERT_KEY_PATH"
renewEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH"
renewEnvCertPEMPath = "LEGO_CERT_PEM_PATH"
renewEnvCertPFXPath = "LEGO_CERT_PFX_PATH"
flgForceCertDomains = "force-cert-domains"
)
func createRenew() *cli.Command {
@ -46,24 +39,37 @@ func createRenew() *cli.Command {
Before: func(ctx *cli.Context) error {
// we require either domains or csr, but not both
hasDomains := len(ctx.StringSlice(flgDomains)) > 0
hasCsr := ctx.String(flgCSR) != ""
if hasDomains && hasCsr {
log.Fatal("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR)
log.Fatalf("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR)
}
if !hasDomains && !hasCsr {
log.Fatal("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR)
log.Fatalf("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR)
}
if ctx.Bool(flgForceCertDomains) && hasCsr {
log.Fatalf("--%s only works with --%s/-d, --%s/-c doesn't support this option.", flgForceCertDomains, flgDomains, flgCSR)
}
return nil
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: flgDays,
Name: flgRenewDays,
Value: 30,
Usage: "The number of days left on a certificate to renew it.",
},
// TODO(ldez): in v5, remove this flag, use this behavior as default.
&cli.BoolFlag{
Name: flgRenewDynamic,
Value: false,
Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.",
},
&cli.BoolFlag{
Name: flgARIDisable,
Usage: "Do not use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed.",
Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.",
},
&cli.DurationFlag{
Name: flgARIWaitToRenewDuration,
@ -97,6 +103,10 @@ func createRenew() *cli.Command {
Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." +
" If no match, the default offered chain will be used.",
},
&cli.StringFlag{
Name: flgProfile,
Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.",
},
&cli.StringFlag{
Name: flgAlwaysDeactivateAuthorizations,
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
@ -105,18 +115,26 @@ func createRenew() *cli.Command {
Name: flgRenewHook,
Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.",
},
&cli.DurationFlag{
Name: flgRenewHookTimeout,
Usage: "Define the timeout for the hook execution.",
Value: 2 * time.Minute,
},
&cli.BoolFlag{
Name: flgNoRandomSleep,
Usage: "Do not add a random sleep before the renewal." +
" We do not recommend using this flag if you are doing your renewals in an automated way.",
},
&cli.BoolFlag{
Name: flgForceCertDomains,
Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.",
},
},
}
}
func renew(ctx *cli.Context) error {
account, client := setup(ctx, NewAccountsStorage(ctx))
setupChallenges(ctx, client)
account, keyType := setupAccount(ctx, NewAccountsStorage(ctx))
if account.Registration == nil {
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email)
@ -126,18 +144,20 @@ func renew(ctx *cli.Context) error {
bundle := !ctx.Bool(flgNoBundle)
meta := map[string]string{renewEnvAccountEmail: account.Email}
meta := map[string]string{
hookEnvAccountEmail: account.Email,
}
// CSR
if ctx.IsSet(flgCSR) {
return renewForCSR(ctx, client, certsStorage, bundle, meta)
return renewForCSR(ctx, account, keyType, certsStorage, bundle, meta)
}
// Domains
return renewForDomains(ctx, client, certsStorage, bundle, meta)
return renewForDomains(ctx, account, keyType, certsStorage, bundle, meta)
}
func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
domains := ctx.StringSlice(flgDomains)
domain := domains[0]
@ -151,10 +171,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
cert := certificates[0]
var ariRenewalTime *time.Time
var replacesCertID string
var (
ariRenewalTime *time.Time
replacesCertID string
)
var client *lego.Client
if !ctx.Bool(flgARIDisable) {
client = setupClient(ctx, account, keyType)
ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client)
if ariRenewalTime != nil {
now := time.Now().UTC()
@ -172,17 +198,25 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
}
}
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) {
forceDomains := ctx.Bool(flgForceCertDomains)
certDomains := certcrypto.ExtractDomains(cert)
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) &&
(!forceDomains || slices.Equal(certDomains, domains)) {
return nil
}
if client == nil {
client = setupClient(ctx, account, keyType)
}
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
certDomains := certcrypto.ExtractDomains(cert)
var privateKey crypto.PrivateKey
if ctx.Bool(flgReuseKey) {
keyBytes, errR := certsStorage.ReadFile(domain, keyExt)
if errR != nil {
@ -200,6 +234,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
if !isatty.IsTerminal(os.Stdout.Fd()) && !ctx.Bool(flgNoRandomSleep) {
// https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472
const jitter = 8 * time.Minute
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
sleepTime := time.Duration(rnd.Int63n(int64(jitter)))
@ -207,14 +242,20 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
time.Sleep(sleepTime)
}
renewalDomains := slices.Clone(domains)
if !forceDomains {
renewalDomains = merge(certDomains, domains)
}
request := certificate.ObtainRequest{
Domains: merge(certDomains, domains),
Domains: renewalDomains,
PrivateKey: privateKey,
MustStaple: ctx.Bool(flgMustStaple),
NotBefore: getTime(ctx, flgNotBefore),
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
@ -227,14 +268,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
log.Fatal(err)
}
certRes.Domain = domain
certsStorage.SaveResource(certRes)
addPathToMetadata(meta, domain, certRes, certsStorage)
return launchHook(ctx.String(flgRenewHook), meta)
return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta)
}
func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
csr, err := readCSRFile(ctx.String(flgCSR))
if err != nil {
log.Fatal(err)
@ -255,10 +298,16 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
cert := certificates[0]
var ariRenewalTime *time.Time
var replacesCertID string
var (
ariRenewalTime *time.Time
replacesCertID string
)
var client *lego.Client
if !ctx.Bool(flgARIDisable) {
client = setupClient(ctx, account, keyType)
ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client)
if ariRenewalTime != nil {
now := time.Now().UTC()
@ -276,10 +325,14 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
}
}
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) {
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) {
return nil
}
if client == nil {
client = setupClient(ctx, account, keyType)
}
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
@ -290,6 +343,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
@ -306,24 +360,51 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
addPathToMetadata(meta, domain, certRes, certsStorage)
return launchHook(ctx.String(flgRenewHook), meta)
return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta)
}
func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool {
func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool {
if x509Cert.IsCA {
log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain)
}
if days >= 0 {
notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
if notAfter > days {
log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
domain, notAfter, days)
return false
}
if dynamic {
return needRenewalDynamic(x509Cert, domain, time.Now())
}
return true
if days < 0 {
return true
}
notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
if notAfter <= days {
return true
}
log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
domain, notAfter, days)
return false
}
func needRenewalDynamic(x509Cert *x509.Certificate, domain string, now time.Time) bool {
lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore)
var divisor int64 = 3
if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 {
divisor = 2
}
dueDate := x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor))
if dueDate.Before(now) {
return true
}
log.Infof("[%s] The certificate expires at %s, the renewal can be performed in %s: no renewal.",
domain, x509Cert.NotAfter.Format(time.RFC3339), dueDate.Sub(now))
return false
}
// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.
@ -339,16 +420,20 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string,
log.Warnf("[%s] acme: %v", domain, err)
return nil
}
log.Warnf("[%s] acme: calling renewal info endpoint: %v", domain, err)
return nil
}
now := time.Now().UTC()
renewalTime := renewalInfo.ShouldRenewAt(now, ctx.Duration(flgARIWaitToRenewDuration))
if renewalTime == nil {
log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is not needed", domain)
return nil
}
log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is needed", domain)
if renewalInfo.ExplanationURL != "" {
@ -358,24 +443,6 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string,
return renewalTime
}
func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) {
meta[renewEnvCertDomain] = domain
meta[renewEnvCertPath] = certsStorage.GetFileName(domain, certExt)
meta[renewEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt)
if certRes.IssuerCertificate != nil {
meta[renewEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt)
}
if certsStorage.pem {
meta[renewEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt)
}
if certsStorage.pfx {
meta[renewEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt)
}
}
func merge(prevDomains, nextDomains []string) []string {
for _, next := range nextDomains {
if slices.Contains(prevDomains, next) {

View file

@ -108,9 +108,62 @@ func Test_needRenewal(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
actual := needRenewal(test.x509Cert, "foo.com", test.days)
actual := needRenewal(test.x509Cert, "foo.com", test.days, false)
assert.Equal(t, test.expected, actual)
})
}
}
func Test_needRenewalDynamic(t *testing.T) {
testCases := []struct {
desc string
now time.Time
notBefore, notAfter time.Time
expected assert.BoolAssertionFunc
}{
{
desc: "higher than 1/3 of the certificate lifetime left (lifetime > 10 days)",
now: time.Date(2025, 1, 19, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
expected: assert.False,
},
{
desc: "lower than 1/3 of the certificate lifetime left(lifetime > 10 days)",
now: time.Date(2025, 1, 21, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
expected: assert.True,
},
{
desc: "higher than 1/2 of the certificate lifetime left (lifetime < 10 days)",
now: time.Date(2025, 1, 4, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
expected: assert.False,
},
{
desc: "lower than 1/2 of the certificate lifetime left (lifetime < 10 days)",
now: time.Date(2025, 1, 6, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
expected: assert.True,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
x509Cert := &x509.Certificate{
NotBefore: test.notBefore,
NotAfter: test.notAfter,
}
ok := needRenewalDynamic(x509Cert, "example.com", test.now)
test.expected(t, ok)
})
}
}

View file

@ -38,12 +38,14 @@ func createRevoke() *cli.Command {
}
func revoke(ctx *cli.Context) error {
acc, client := setup(ctx, NewAccountsStorage(ctx))
account, keyType := setupAccount(ctx, NewAccountsStorage(ctx))
if acc.Registration == nil {
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email)
if account.Registration == nil {
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email)
}
client := newClient(ctx, account, keyType)
certsStorage := NewCertificatesStorage(ctx)
certsStorage.CreateRootFolder()

View file

@ -20,9 +20,12 @@ const (
flgMustStaple = "must-staple"
flgNotBefore = "not-before"
flgNotAfter = "not-after"
flgPrivateKey = "private-key"
flgPreferredChain = "preferred-chain"
flgProfile = "profile"
flgAlwaysDeactivateAuthorizations = "always-deactivate-authorizations"
flgRunHook = "run-hook"
flgRunHookTimeout = "run-hook-timeout"
)
func createRun() *cli.Command {
@ -32,13 +35,16 @@ func createRun() *cli.Command {
Before: func(ctx *cli.Context) error {
// we require either domains or csr, but not both
hasDomains := len(ctx.StringSlice(flgDomains)) > 0
hasCsr := ctx.String(flgCSR) != ""
if hasDomains && hasCsr {
log.Fatal("Please specify either --domains/-d or --csr/-c, but not both")
}
if !hasDomains && !hasCsr {
log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
}
return nil
},
Action: run,
@ -62,11 +68,19 @@ func createRun() *cli.Command {
Usage: "Set the notAfter field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
&cli.StringFlag{
Name: flgPrivateKey,
Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.",
},
&cli.StringFlag{
Name: flgPreferredChain,
Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." +
" If no match, the default offered chain will be used.",
},
&cli.StringFlag{
Name: flgProfile,
Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.",
},
&cli.StringFlag{
Name: flgAlwaysDeactivateAuthorizations,
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
@ -75,26 +89,32 @@ func createRun() *cli.Command {
Name: flgRunHook,
Usage: "Define a hook. The hook is executed when the certificates are effectively created.",
},
&cli.DurationFlag{
Name: flgRunHookTimeout,
Usage: "Define the timeout for the hook execution.",
Value: 2 * time.Minute,
},
},
}
}
const rootPathWarningMessage = `!!!! HEADS UP !!!!
Your account credentials have been saved in your Let's Encrypt
Your account credentials have been saved in your
configuration directory at "%s".
You should make a secure backup of this folder now. This
configuration directory will also contain certificates and
private keys obtained from Let's Encrypt so making regular
backups of this folder is ideal.
configuration directory will also contain private keys
generated by lego and certificates obtained from the ACME
server. Making regular backups of this folder is ideal.
`
func run(ctx *cli.Context) error {
accountsStorage := NewAccountsStorage(ctx)
account, client := setup(ctx, accountsStorage)
setupChallenges(ctx, client)
account, keyType := setupAccount(ctx, accountsStorage)
client := setupClient(ctx, account, keyType)
if account.Registration == nil {
reg, err := register(ctx, client)
@ -123,12 +143,12 @@ func run(ctx *cli.Context) error {
certsStorage.SaveResource(cert)
meta := map[string]string{
renewEnvAccountEmail: account.Email,
hookEnvAccountEmail: account.Email,
}
addPathToMetadata(meta, cert.Domain, cert, certsStorage)
return launchHook(ctx.String(flgRunHook), meta)
return launchHook(ctx.String(flgRunHook), ctx.Duration(flgRunHookTimeout), meta)
}
func handleTOS(ctx *cli.Context, client *lego.Client) bool {
@ -138,10 +158,12 @@ func handleTOS(ctx *cli.Context, client *lego.Client) bool {
}
reader := bufio.NewReader(os.Stdin)
log.Printf("Please review the TOS at %s", client.GetToSURL())
for {
fmt.Println("Do you accept the TOS? Y/n")
text, err := reader.ReadString('\n')
if err != nil {
log.Fatalf("Could not read from console: %v", err)
@ -191,20 +213,22 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
// obtain a certificate, generating a new private key
request := certificate.ObtainRequest{
Domains: domains,
Bundle: bundle,
MustStaple: ctx.Bool(flgMustStaple),
NotBefore: getTime(ctx, flgNotBefore),
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
notBefore := ctx.Timestamp(flgNotBefore)
if notBefore != nil {
request.NotBefore = *notBefore
}
if ctx.IsSet(flgPrivateKey) {
var err error
notAfter := ctx.Timestamp(flgNotAfter)
if notAfter != nil {
request.NotAfter = *notAfter
request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey))
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}
}
return client.Certificate.Obtain(request)
@ -223,8 +247,18 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
if ctx.IsSet(flgPrivateKey) {
var err error
request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey))
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}
}
return client.Certificate.ObtainForCSR(request)
}

View file

@ -16,6 +16,7 @@ const (
flgServer = "server"
flgAcceptTOS = "accept-tos"
flgEmail = "email"
flgDisableCommonName = "disable-cn"
flgCSR = "csr"
flgEAB = "eab"
flgKID = "kid"
@ -25,12 +26,14 @@ const (
flgPath = "path"
flgHTTP = "http"
flgHTTPPort = "http.port"
flgHTTPDelay = "http.delay"
flgHTTPProxyHeader = "http.proxy-header"
flgHTTPWebroot = "http.webroot"
flgHTTPMemcachedHost = "http.memcached-host"
flgHTTPS3Bucket = "http.s3-bucket"
flgTLS = "tls"
flgTLSPort = "tls.port"
flgTLSDelay = "tls.delay"
flgDNS = "dns"
flgDNSDisableCP = "dns.disable-cp"
flgDNSPropagationWait = "dns.propagation-wait"
@ -49,6 +52,18 @@ const (
flgUserAgent = "user-agent"
)
const (
envEAB = "LEGO_EAB"
envEABHMAC = "LEGO_EAB_HMAC"
envEABKID = "LEGO_EAB_KID"
envEmail = "LEGO_EMAIL"
envPath = "LEGO_PATH"
envPFX = "LEGO_PFX"
envPFXFormat = "LEGO_PFX_FORMAT"
envPFXPassword = "LEGO_PFX_PASSWORD"
envServer = "LEGO_SERVER"
)
func CreateFlags(defaultPath string) []cli.Flag {
return []cli.Flag{
&cli.StringSliceFlag{
@ -59,7 +74,7 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.StringFlag{
Name: flgServer,
Aliases: []string{"s"},
EnvVars: []string{"LEGO_SERVER"},
EnvVars: []string{envServer},
Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.",
Value: lego.LEDirectoryProduction,
},
@ -71,8 +86,13 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.StringFlag{
Name: flgEmail,
Aliases: []string{"m"},
EnvVars: []string{envEmail},
Usage: "Email used for registration and recovery contact.",
},
&cli.BoolFlag{
Name: flgDisableCommonName,
Usage: "Disable the use of the common name in the CSR.",
},
&cli.StringFlag{
Name: flgCSR,
Aliases: []string{"c"},
@ -80,17 +100,17 @@ func CreateFlags(defaultPath string) []cli.Flag {
},
&cli.BoolFlag{
Name: flgEAB,
EnvVars: []string{"LEGO_EAB"},
EnvVars: []string{envEAB},
Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.",
},
&cli.StringFlag{
Name: flgKID,
EnvVars: []string{"LEGO_EAB_KID"},
EnvVars: []string{envEABKID},
Usage: "Key identifier from External CA. Used for External Account Binding.",
},
&cli.StringFlag{
Name: flgHMAC,
EnvVars: []string{"LEGO_EAB_HMAC"},
EnvVars: []string{envEABHMAC},
Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.",
},
&cli.StringFlag{
@ -105,7 +125,7 @@ func CreateFlags(defaultPath string) []cli.Flag {
},
&cli.StringFlag{
Name: flgPath,
EnvVars: []string{"LEGO_PATH"},
EnvVars: []string{envPath},
Usage: "Directory to use for storing the data.",
Value: defaultPath,
},
@ -118,6 +138,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
Usage: "Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port.",
Value: ":80",
},
&cli.DurationFlag{
Name: flgHTTPDelay,
Usage: "Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge.",
Value: 0,
},
&cli.StringFlag{
Name: flgHTTPProxyHeader,
Usage: "Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy.",
@ -145,6 +170,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
Usage: "Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port.",
Value: ":443",
},
&cli.DurationFlag{
Name: flgTLSDelay,
Usage: "Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge.",
Value: 0,
},
&cli.StringFlag{
Name: flgDNS,
Usage: "Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.",
@ -192,19 +222,19 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.BoolFlag{
Name: flgPFX,
Usage: "Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together.",
EnvVars: []string{"LEGO_PFX"},
EnvVars: []string{envPFX},
},
&cli.StringFlag{
Name: flgPFXPass,
Usage: "The password used to encrypt the .pfx (PCKS#12) file.",
Value: pkcs12.DefaultPassword,
EnvVars: []string{"LEGO_PFX_PASSWORD"},
EnvVars: []string{envPFXPassword},
},
&cli.StringFlag{
Name: flgPFXFormat,
Usage: "The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256.",
Value: "RC2",
EnvVars: []string{"LEGO_PFX_FORMAT"},
EnvVars: []string{envPFXFormat},
},
&cli.IntFlag{
Name: flgCertTimeout,
@ -228,5 +258,6 @@ func getTime(ctx *cli.Context, name string) time.Time {
if value == nil {
return time.Time{}
}
return *value
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"bufio"
"context"
"errors"
"fmt"
@ -8,32 +9,70 @@ import (
"os/exec"
"strings"
"time"
"github.com/go-acme/lego/v4/certificate"
)
func launchHook(hook string, meta map[string]string) error {
const (
hookEnvAccountEmail = "LEGO_ACCOUNT_EMAIL"
hookEnvCertDomain = "LEGO_CERT_DOMAIN"
hookEnvCertPath = "LEGO_CERT_PATH"
hookEnvCertKeyPath = "LEGO_CERT_KEY_PATH"
hookEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH"
hookEnvCertPEMPath = "LEGO_CERT_PEM_PATH"
hookEnvCertPFXPath = "LEGO_CERT_PFX_PATH"
)
func launchHook(hook string, timeout time.Duration, meta map[string]string) error {
if hook == "" {
return nil
}
ctxCmd, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctxCmd, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
parts := strings.Fields(hook)
cmdCtx := exec.CommandContext(ctxCmd, parts[0], parts[1:]...)
cmdCtx.Env = append(os.Environ(), metaToEnv(meta)...)
cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...)
output, err := cmdCtx.CombinedOutput()
cmd.Env = append(os.Environ(), metaToEnv(meta)...)
if len(output) > 0 {
fmt.Println(string(output))
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("create pipe: %w", err)
}
if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) {
return errors.New("hook timed out")
cmd.Stderr = cmd.Stdout
err = cmd.Start()
if err != nil {
return fmt.Errorf("start command: %w", err)
}
return err
go func() {
<-ctxCmd.Done()
if ctxCmd.Err() != nil {
_ = cmd.Process.Kill()
_ = stdout.Close()
}
}()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
err = cmd.Wait()
if err != nil {
if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) {
return errors.New("hook timed out")
}
return fmt.Errorf("wait command: %w", err)
}
return nil
}
func metaToEnv(meta map[string]string) []string {
@ -45,3 +84,21 @@ func metaToEnv(meta map[string]string) []string {
return envs
}
func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) {
meta[hookEnvCertDomain] = domain
meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt)
meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt)
if certRes.IssuerCertificate != nil {
meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt)
}
if certsStorage.pem {
meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt)
}
if certsStorage.pfx {
meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt)
}
}

61
cmd/hook_test.go Normal file
View file

@ -0,0 +1,61 @@
package cmd
import (
"runtime"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func Test_launchHook(t *testing.T) {
err := launchHook("echo foo", 1*time.Second, map[string]string{})
require.NoError(t, err)
}
func Test_launchHook_errors(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping test on Windows")
}
testCases := []struct {
desc string
hook string
timeout time.Duration
expected string
}{
{
desc: "kill the hook",
hook: "sleep 5",
timeout: 1 * time.Second,
expected: "hook timed out",
},
{
desc: "context timeout on Start",
hook: "echo foo",
timeout: 1 * time.Nanosecond,
expected: "start command: context deadline exceeded",
},
{
desc: "multiple short sleeps",
hook: "./testdata/sleepy.sh",
timeout: 1 * time.Second,
expected: "hook timed out",
},
{
desc: "long sleep",
hook: "./testdata/sleeping_beauty.sh",
timeout: 1 * time.Second,
expected: "hook timed out",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := launchHook(test.hook, test.timeout, map[string]string{})
require.EqualError(t, err, test.expected)
})
}
}

View file

@ -26,6 +26,7 @@ func main() {
}
var defaultPath string
cwd, err := os.Getwd()
if err == nil {
defaultPath = filepath.Join(cwd, ".lego")

View file

@ -2,7 +2,7 @@
package main
const defaultVersion = "v4.20.2+dev-release"
const defaultVersion = "v4.32.0+dev-detach"
var version = ""

View file

@ -1,25 +1,38 @@
package cmd
import (
"crypto/tls"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/registration"
"github.com/hashicorp/go-retryablehttp"
"github.com/urfave/cli/v2"
)
const filePerm os.FileMode = 0o600
func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.Client) {
// setupClient creates a new client with challenge settings.
func setupClient(ctx *cli.Context, account *Account, keyType certcrypto.KeyType) *lego.Client {
client := newClient(ctx, account, keyType)
setupChallenges(ctx, client)
return client
}
func setupAccount(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, certcrypto.KeyType) {
keyType := getKeyType(ctx)
privateKey := accountsStorage.GetPrivateKey(keyType)
@ -27,12 +40,10 @@ func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.
if accountsStorage.ExistsAccountFilePath() {
account = accountsStorage.LoadAccount(privateKey)
} else {
account = &Account{Email: accountsStorage.GetUserID(), key: privateKey}
account = &Account{Email: accountsStorage.GetEmail(), key: privateKey}
}
client := newClient(ctx, account, keyType)
return account, client
return account, keyType
}
func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyType) *lego.Client {
@ -43,6 +54,7 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy
KeyType: keyType,
Timeout: time.Duration(ctx.Int(flgCertTimeout)) * time.Second,
OverallRequestLimit: ctx.Int(flgOverallRequestLimit),
DisableCommonName: ctx.Bool(flgDisableCommonName),
}
config.UserAgent = getUserAgent(ctx)
@ -51,11 +63,26 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy
}
if ctx.Bool(flgTLSSkipVerify) {
config.HTTPClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
defaultTransport, ok := config.HTTPClient.Transport.(*http.Transport)
if ok { // This is always true because the default client used by the CLI defined the transport.
tr := defaultTransport.Clone()
tr.TLSClientConfig.InsecureSkipVerify = true
config.HTTPClient.Transport = tr
}
}
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 5
retryClient.HTTPClient = config.HTTPClient
retryClient.CheckRetry = checkRetry
retryClient.Logger = nil
if _, v := os.LookupEnv("LEGO_DEBUG_ACME_HTTP_CLIENT"); v {
retryClient.Logger = log.Logger
}
config.HTTPClient = retryClient.StandardClient()
client, err := lego.NewClient(config)
if err != nil {
log.Fatalf("Could not create client: %v", err)
@ -87,15 +114,8 @@ func getKeyType(ctx *cli.Context) certcrypto.KeyType {
}
log.Fatalf("Unsupported KeyType: %s", keyType)
return ""
}
func getEmail(ctx *cli.Context) string {
email := ctx.String(flgEmail)
if email == "" {
log.Fatalf("You have to pass an account (email address) to the program using --%s or -m", flgEmail)
}
return email
return ""
}
func getUserAgent(ctx *cli.Context) string {
@ -108,6 +128,7 @@ func createNonExistingFolder(path string) error {
} else if err != nil {
return err
}
return nil
}
@ -116,10 +137,12 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) {
if err != nil {
return nil, err
}
raw := bytes
// see if we can find a PEM-encoded CSR
var p *pem.Block
rest := bytes
for {
// decode a PEM block
@ -141,3 +164,49 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) {
// (if this assumption is wrong, parsing these bytes will fail)
return x509.ParseCertificateRequest(raw)
}
func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
rt, err := retryablehttp.ErrorPropagatedRetryPolicy(ctx, resp, err)
if err != nil {
return rt, err
}
if resp == nil {
return rt, nil
}
if resp.StatusCode/100 == 2 {
return rt, nil
}
all, err := io.ReadAll(resp.Body)
if err == nil {
var errorDetails *acme.ProblemDetails
err = json.Unmarshal(all, &errorDetails)
if err != nil {
return rt, fmt.Errorf("%s %s: %s", resp.Request.Method, resp.Request.URL.Redacted(), string(all))
}
switch errorDetails.Type {
case acme.BadNonceErr:
return false, &acme.NonceError{
ProblemDetails: errorDetails,
}
case acme.AlreadyReplacedErr:
if errorDetails.HTTPStatus == http.StatusConflict {
return false, &acme.AlreadyReplacedError{
ProblemDetails: errorDetails,
}
}
default:
log.Warnf("retry: %v", errorDetails)
return rt, errorDetails
}
}
return rt, nil
}

View file

@ -25,14 +25,14 @@ func setupChallenges(ctx *cli.Context, client *lego.Client) {
}
if ctx.Bool(flgHTTP) {
err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx))
err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx), http01.SetDelay(ctx.Duration(flgHTTPDelay)))
if err != nil {
log.Fatal(err)
}
}
if ctx.Bool(flgTLS) {
err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx))
err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx), tlsalpn01.SetDelay(ctx.Duration(flgTLSDelay)))
if err != nil {
log.Fatal(err)
}
@ -54,18 +54,21 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider {
if err != nil {
log.Fatal(err)
}
return ps
case ctx.IsSet(flgHTTPMemcachedHost):
ps, err := memcached.NewMemcachedProvider(ctx.StringSlice(flgHTTPMemcachedHost))
if err != nil {
log.Fatal(err)
}
return ps
case ctx.IsSet(flgHTTPS3Bucket):
ps, err := s3.NewHTTPProvider(ctx.String(flgHTTPS3Bucket))
if err != nil {
log.Fatal(err)
}
return ps
case ctx.IsSet(flgHTTPPort):
iface := ctx.String(flgHTTPPort)
@ -82,12 +85,14 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider {
if header := ctx.String(flgHTTPProxyHeader); header != "" {
srv.SetProxyHeader(header)
}
return srv
case ctx.Bool(flgHTTP):
srv := http01.NewProviderServer("", "")
if header := ctx.String(flgHTTPProxyHeader); header != "" {
srv.SetProxyHeader(header)
}
return srv
default:
log.Fatal("Invalid HTTP challenge options.")

3
cmd/testdata/sleeping_beauty.sh vendored Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash -e
sleep 50

7
cmd/testdata/sleepy.sh vendored Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash -e
for i in `seq 1 10`
do
echo $i
sleep 0.2
done

2236
cmd/zz_gen_cmd_dnshelp.go generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
.PHONY: default clean hugo hugo-build
.PHONY: default clean serve build
default: clean hugo
default: clean serve
clean:
rm -rf public/
hugo-build: clean
build: clean
hugo --enableGitInfo --source .
hugo:
serve:
hugo server --disableFastRender --enableGitInfo --watch --source .
# hugo server -D

View file

@ -7,23 +7,34 @@ chapter: false
Let's Encrypt client and ACME library written in Go.
{{% notice important %}}
lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
This project is not owned by a company. I'm not an employee of a company.
I don't have gifted domains/accounts from DNS companies.
I've been maintaining it for about 10 years.
{{% /notice %}}
## Features
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS ApplicationLayer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses
- Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension
- Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension
- Comes with about [180 DNS providers]({{% ref "dns" %}})
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
- Revoke certificates
- Robust implementation of all ACME challenges
- Robust implementation of ACME challenges:
- HTTP (http-01)
- DNS (dns-01)
- TLS (tls-alpn-01)
- SAN certificate support
- [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default
- Comes with multiple optional [DNS providers]({{% ref "dns" %}})
- [Custom challenge solvers]({{% ref "usage/library/Writing-a-Challenge-Solver" %}})
- Certificate bundling
- OCSP helper function

View file

@ -5,6 +5,16 @@ draft: false
weight: 3
---
{{% notice important %}}
lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
This project is not owned by a company. I'm not an employee of a company.
I don't have gifted domains/accounts from DNS companies.
I've been maintaining it for about 10 years.
{{% /notice %}}
## Configuration and Credentials
Credentials and DNS configuration for DNS providers must be passed through environment variables.

View file

@ -28,7 +28,13 @@ Here is an example bash command using the Joohoi's ACME-DNS provider:
```bash
ACME_DNS_API_BASE=http://10.0.0.8:4443 \
ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \
lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run
lego --dns "acme-dns" -d '*.example.com' -d example.com run
# or
ACME_DNS_API_BASE=http://10.0.0.8:4443 \
ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \
lego --dns "acme-dns" -d '*.example.com' -d example.com run
```
@ -39,20 +45,29 @@ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ACME_DNS_API_BASE` | The ACME-DNS API address |
| `ACME_DNS_STORAGE_BASE_URL` | The ACME-DNS JSON account data server. |
| `ACME_DNS_STORAGE_PATH` | The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates. |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ACME_DNS_ALLOWLIST` | Source networks using CIDR notation (multiple values should be separated with a comma). |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://github.com/joohoi/acme-dns#api)
- [Go client](https://github.com/cpu/goacmedns)
- [Go client](https://github.com/nrdcg/goacmedns)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/acmedns/acmedns.toml -->

69
docs/content/dns/zz_gen_active24.md generated Normal file
View file

@ -0,0 +1,69 @@
---
title: "Active24"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: active24
dnsprovider:
since: "v4.23.0"
code: "active24"
url: "https://www.active24.cz"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/active24/active24.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [Active24](https://www.active24.cz).
<!--more-->
- Code: `active24`
- Since: v4.23.0
Here is an example bash command using the Active24 provider:
```bash
ACTIVE24_API_KEY="xxx" \
ACTIVE24_SECRET="yyy" \
lego --dns active24 -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ACTIVE24_API_KEY` | API key |
| `ACTIVE24_SECRET` | Secret |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ACTIVE24_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ACTIVE24_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ACTIVE24_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `ACTIVE24_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://rest.active24.cz/v2/docs)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/active24/active24.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -28,13 +28,13 @@ Here is an example bash command using the Alibaba Cloud DNS provider:
```bash
# Setup using instance RAM role
ALICLOUD_RAM_ROLE=lego \
lego --email you@example.com --dns alidns -d '*.example.com' -d example.com run
lego --dns alidns -d '*.example.com' -d example.com run
# Or, using credentials
ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
ALICLOUD_SECRET_KEY=your-secret-key \
ALICLOUD_SECURITY_TOKEN=your-sts-token \
lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com run
lego --dns alidns - -d '*.example.com' -d example.com run
```
@ -45,7 +45,7 @@ lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com ru
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ALICLOUD_ACCESS_KEY` | Access key ID |
| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm) |
| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) |
| `ALICLOUD_SECRET_KEY` | Access Key secret |
| `ALICLOUD_SECURITY_TOKEN` | STS Security Token (optional) |
@ -57,10 +57,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALICLOUD_HTTP_TIMEOUT` | API request timeout |
| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
| `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
| `ALICLOUD_LINE` | Line (Default: default) |
| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) |
| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@ -71,7 +73,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records)
- [Go client](https://github.com/aliyun/alibaba-cloud-sdk-go)
- [Go client](https://github.com/alibabacloud-go/alidns-20150109)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/alidns/alidns.toml -->

78
docs/content/dns/zz_gen_aliesa.md generated Normal file
View file

@ -0,0 +1,78 @@
---
title: "AlibabaCloud ESA"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: aliesa
dnsprovider:
since: "v4.29.0"
code: "aliesa"
url: "https://www.alibabacloud.com/en/product/esa"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/aliesa/aliesa.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [AlibabaCloud ESA](https://www.alibabacloud.com/en/product/esa).
<!--more-->
- Code: `aliesa`
- Since: v4.29.0
Here is an example bash command using the AlibabaCloud ESA provider:
```bash
# Setup using instance RAM role
ALIESA_RAM_ROLE=lego \
lego --dns aliesa -d '*.example.com' -d example.com run
# Or, using credentials
ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
ALIESA_SECRET_KEY=your-secret-key \
ALIESA_SECURITY_TOKEN=your-sts-token \
lego --dns aliesa - -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ALIESA_ACCESS_KEY` | Access key ID |
| `ALIESA_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) |
| `ALIESA_SECRET_KEY` | Access Key secret |
| `ALIESA_SECURITY_TOKEN` | STS Security Token (optional) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALIESA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ALIESA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALIESA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `ALIESA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records)
- [Go client](https://github.com/alibabacloud-go/esa-20240910)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/aliesa/aliesa.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -28,7 +28,7 @@ Here is an example bash command using the all-inkl provider:
```bash
ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run
lego --dns allinkl -d '*.example.com' -d example.com run
```
@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALL_INKL_HTTP_TIMEOUT` | API request timeout |
| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check |
| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
| `ALL_INKL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).

68
docs/content/dns/zz_gen_alwaysdata.md generated Normal file
View file

@ -0,0 +1,68 @@
---
title: "Alwaysdata"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: alwaysdata
dnsprovider:
since: "v4.31.0"
code: "alwaysdata"
url: "https://alwaysdata.com/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/alwaysdata/alwaysdata.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [Alwaysdata](https://alwaysdata.com/).
<!--more-->
- Code: `alwaysdata`
- Since: v4.31.0
Here is an example bash command using the Alwaysdata provider:
```bash
ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
lego --dns alwaysdata -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ALWAYSDATA_API_KEY` | API Key |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALWAYSDATA_ACCOUNT` | Account name |
| `ALWAYSDATA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ALWAYSDATA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALWAYSDATA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `ALWAYSDATA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://help.alwaysdata.com/en/api/resources/)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/alwaysdata/alwaysdata.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

73
docs/content/dns/zz_gen_anexia.md generated Normal file
View file

@ -0,0 +1,73 @@
---
title: "Anexia CloudDNS"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: anexia
dnsprovider:
since: "v4.28.0"
code: "anexia"
url: "https://www.anexia-it.com/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/anexia/anexia.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [Anexia CloudDNS](https://www.anexia-it.com/).
<!--more-->
- Code: `anexia`
- Since: v4.28.0
Here is an example bash command using the Anexia CloudDNS provider:
```bash
ANEXIA_TOKEN=xxx \
lego --dns anexia -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ANEXIA_TOKEN` | API token for Anexia Engine |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ANEXIA_API_URL` | API endpoint URL (default: https://engine.anexia-it.com) |
| `ANEXIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ANEXIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ANEXIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
| `ANEXIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Description
You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/).
The token must have permissions to manage DNS zones and records.
## More information
- [API documentation](https://engine.anexia-it.com/docs/en/module/clouddns/api)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/anexia/anexia.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

Some files were not shown because too many files have changed in this diff Show more