acme-proxy 0.6.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
# Example configuration for acme-proxy.
#
# To use these settings, copy this file to `config.toml` (which is gitignored)
# and edit it. Alternatively, since every value below matches the built-in default,
# the server will run with these defaults if no config file is present.
#
# Environment Overrides:
# Any value can be overridden by an environment variable prefixed with `ACME_PROXY_`,
# using `__` (double underscore) as a separator between the table and the key.
# Example: `ACME_PROXY_SERVER__BASE_URL=https://acme.example.com`
# To specify a custom config file path, use `ACME_PROXY_CONFIG=/path/to/config`.
#
# Every list-valued key takes a comma-separated value from the environment,
# e.g. `ACME_PROXY_CHALLENGE__ENABLED=http-01,dns-01`. Items are trimmed, and an
# empty item is refused. A regex containing a literal comma — a quantifier such
# as `{2,3}` — can only be written in this file.
#
# Profiles:
# The server serves ACME only through the [profiles.<name>] tables at the bottom
# of this file — at least one is required, or startup fails. Each is mounted at
# /profile/<name>, so a profile named `default` answers at
# {base_url}/profile/default/directory. See that section for what a profile
# inherits and what it can override.
#
# ---------------------------------------------------------------------------
# Sections in this file, in order. Sections marked [per-profile] may also
# appear inside a [profiles.<name>] block, where they override the value set
# here; everything else is process-wide, one setting for the whole server.
#
#   [database]                          the SQLite file or PostgreSQL server
#   [server]                            listen socket, public URL, admission
#   [server.tls]                        HTTPS on the ACME listener
#   [admin]                             the web admin listener and its sessions
#   [admin.notify]                      operator security notifications
#   [admin.filter]                      who may reach the admin listener
#   [admin.tls]                         HTTPS on the admin listener
#   [nonce]                             replay-nonce freshness
#   [audit]                             reverse lookups and trail retention
#   [jobs]                              the durable background-work queue
#   [metrics]                           the Prometheus listener
#   [logging]                           filter, format, target
#   [order]                    [per-profile]  the order object's lifetime
#   [signer]                   [per-profile]  how a certificate is obtained
#     [signer.local_ca]                 the embedded CA
#     [signer.local_ca.subject]         its self-generated certificate's subject
#     [signer.local_ca.pkcs11]          its key in a token (--features hsm)
#     [signer.relay]                    relaying to an upstream ACME CA
#     [signer.relay.eab]                upstream EAB bootstrap credential
#     [signer.relay.dns01]              solving the upstream's DNS-01
#     [signer.relay.dns01.rfc2136]      the TSIG-authenticated DNS update
#     [signer.relay.dns01.propagation]  waiting before the upstream looks
#     [signer.custom]                   shelling out to a script
#   [challenge]                [per-profile]  how control of a name is proven
#     [challenge.http_01]               (there is no [challenge.dns_01])
#     [challenge.tls_alpn_01]
#   [filter]                   [per-profile]  who may ask, and for what
#     [filter.check.<name>]             one named check, of one `type`
#     [filter.rule.<name>]              a condition over checks, and its effect
#   [ipam]                     [per-profile]  the inventory `ipam` checks read
#     [ipam.netbox]
#     [ipam.phpipam]
#     [ipam.custom]
#   [eab]                      [per-profile]  External Account Binding
#   [meta]                     [per-profile]  directory `meta` members
#   [notify]                   [per-profile]  outbound notifications
#     [notify.email]
#     [notify.expiry]                   the periodic expiry digest
#     [notify.webhook.<name>]           one table per named HTTP target
#     [notify.custom.<name>]            one table per named script hook
#   [dns]                               the resolver every lookup goes through
#   [proxy]                             the forward proxy outbound clients use
#   [profiles.<name>]                   an ACME endpoint — at least one required
#
# The eight [per-profile] sections are exactly those a profile may override,
# and inheritance is per *key*, not per section: a profile setting only
# challenge.bypass keeps the global challenge.enabled. Arrays replace
# wholesale, never append.
# ---------------------------------------------------------------------------

[database]
# Database connection URL. This database persists ACME orders and nonces.
# The scheme picks the backend: `sqlite://<path>` for a file (created on first
# use), or `postgres://user:pass@host/db` for a server, which must already
# exist and needs `acme-proxy migrate` run against it once. PostgreSQL is what
# a multi-node deployment needs; SQLite is not safe across hosts.
url = "sqlite://sqlite.db"          # ACME_PROXY_DATABASE__URL

[server]
# Network address the server binds to. Controls which interfaces are accessible.
bind_address = "[::]:3000"          # ACME_PROXY_SERVER__BIND_ADDRESS
# Public base URL advertised in the ACME directory and used for JWS validation.
base_url = "http://localhost:3000"  # ACME_PROXY_SERVER__BASE_URL
#
# Admission control for the ACME endpoints. GET /health is deliberately outside
# all of it: a health probe is asked for exactly when the server is saturated.
#
# How many ACME requests may be in flight at once. Past this, a request waits
# admission_wait_ms for a slot and is then refused with 503 + Retry-After rather
# than queued — an unbounded queue only converts a slow server into a backlog of
# clients that gave up long ago but still cost a slot when their turn comes.
max_concurrent_requests = 100       # ACME_PROXY_SERVER__MAX_CONCURRENT_REQUESTS
# How long to wait for a slot before refusing. Long enough to absorb a burst,
# short enough that the queue can never be deeper than this in time.
admission_wait_ms = 50              # ACME_PROXY_SERVER__ADMISSION_WAIT_MS
# Whole-request deadline. It must exceed every hook that still runs inside a
# request, or work that was going to succeed is cut off and reported to the
# client as a server failure. Challenge validation runs in the job queue, so
# that is now only signer.custom.timeout_ms, and only while a custom signer
# answers `GET /crl` (supports_crl) or renewal information
# (supports_renewal_info); startup refuses a configuration where it does not.
request_timeout_ms = 60000          # ACME_PROXY_SERVER__REQUEST_TIMEOUT_MS
# Largest request body accepted, in bytes. An ACME body is a JWS carrying at
# most a CSR; the default here replaces axum's implicit 2 MiB.
max_body_bytes = 131072             # ACME_PROXY_SERVER__MAX_BODY_BYTES

[server.tls]
# Serve HTTPS on server.bind_address instead of cleartext HTTP. There is one
# listener, not two: enabling this replaces the cleartext one. RFC 8555 §6.1
# expects HTTPS, so leaving this off means terminating TLS in front of the
# server — or accepting that ACME traffic is in the clear.
# Remember to change server.base_url to https:// as well: the JWS `url` check is
# a string comparison, and a stale http:// base refuses every signed request.
enabled = false                     # ACME_PROXY_SERVER__TLS__ENABLED
# Certificate chain (leaf first) and its private key, both PEM. When either file
# is missing, a self-signed certificate for the host of server.base_url is
# generated and written on startup — as the local CA and sqlite.db do. The
# generated key is created 0600; a supplied one only earns a warning.
cert_path = "server.pem"            # ACME_PROXY_SERVER__TLS__CERT_PATH
key_path  = "server.key"            # ACME_PROXY_SERVER__TLS__KEY_PATH
# Budget for one TLS handshake. A client that stalls part-way through is dropped
# once it elapses; handshakes run concurrently, so this never delays anyone else.
handshake_timeout_ms = 10000        # ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS

[admin]
# The web admin interface: a SECOND listener, on its own socket, serving no
# ACME. Off by default — a certificate authority does not grow a management
# surface because somebody upgraded it. Bootstrap it with
# `acme-proxy admin user create <username>`; there is no sign-up page.
enabled = false                     # ACME_PROXY_ADMIN__ENABLED
# Loopback on purpose. This listener has no admission control, filters nothing
# until [admin.filter] names a rule, and — until [admin.tls] is on — has no
# transport security; putting it somewhere a client can reach is a decision,
# not a default. Startup REFUSES a non-loopback
# bind while admin.tls.enabled is false, because the session cookie is always
# sent `Secure` and a browser silently declines to store it over plain HTTP on
# anything but localhost: the symptom is "login works, then I am immediately
# logged out", with nothing in any log to explain it.
# To reach it from elsewhere, prefer an SSH tunnel:
#   ssh -N -L 3001:127.0.0.1:3001 <host>
bind_address = "127.0.0.1:3001"     # ACME_PROXY_ADMIN__BIND_ADDRESS
# The origin the panel is reached at. Load-bearing three times over: the CSRF
# origin check compares against it, a generated self-signed certificate takes
# its host, and the pages build absolute URLs from it — exactly as
# server.base_url does for the ACME listener. Through a tunnel, this stays
# localhost.
base_url = "http://localhost:3001"  # ACME_PROXY_ADMIN__BASE_URL
# Absolute session lifetime (12h). Never extended by activity: past it, the
# operator signs in again.
session_ttl_seconds = 43200         # ACME_PROXY_ADMIN__SESSION_TTL_SECONDS
# Idle session lifetime (1h), advanced on use — at most once a minute, so a
# polling page is not a stream of database writes.
session_idle_timeout_seconds = 3600 # ACME_PROXY_ADMIN__SESSION_IDLE_TIMEOUT_SECONDS
# Failed logins allowed from one address per window, then 503-style refusal with
# a Retry-After. The password hash is deliberately expensive (PBKDF2-HMAC-SHA256
# at 600k iterations), so this is an availability control as much as a
# credential one — over the limit, the hash is not computed at all.
login_max_attempts = 5              # ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS
login_window_seconds = 300          # ACME_PROXY_ADMIN__LOGIN_WINDOW_SECONDS
# Require a second factor (TOTP) of every operator.
#
# An operator who already has one is challenged whether this is set or not; what
# this changes is the operator who has NONE — with it on, their next sign-in
# lands on the enrolment page and their session stays half-authenticated until
# they finish. It deliberately does NOT refuse a password-only login outright:
# enrolling needs a session and a session would then need a factor, so turning
# this on with nobody enrolled would brick the panel, including the way in to
# fix it.
#
# Turning it on does not retroactively end sessions that predate it. The lever
# that does is `acme-proxy admin session revoke --all`.
#
# A lost phone is `acme-proxy admin user totp reset <username>` from a shell on
# the host — the same place the first operator was created. There is no
# self-service reset and no sign-up page, by the same reasoning.
require_mfa = false                 # ACME_PROXY_ADMIN__REQUIRE_MFA
# Largest admin request body. An admin body is a small JSON object or a form,
# never a certificate.
max_body_bytes = 65536              # ACME_PROXY_ADMIN__MAX_BODY_BYTES
# Ceiling on ?limit= for the list endpoints; the default page size is 50.
page_size_max = 200                 # ACME_PROXY_ADMIN__PAGE_SIZE_MAX
# Override individual /ui page templates on disk, mirroring
# notify.template_dir: each name is looked for here first and falls back to the
# compiled-in default, so overriding just "layout.html" restyles every page and
# leaves the rest alone. Every template is compiled at startup — a broken
# override refuses to start rather than serving a 500 at three in the morning.
# Empty means the compiled-in defaults.
template_dir = ""                   # ACME_PROXY_ADMIN__TEMPLATE_DIR

# [admin.notify] — where web-admin operator security events go (a sign-in from
# an unfamiliar address, a correct password then a refused second factor, a
# lockout, a credential change — ASVS V6.3.5 / V6.3.7). Exactly the shape of the
# per-profile [notify] section below (email / webhook / custom / template_dir /
# per-backend events), but process-wide and built only when [admin] is enabled.
# Email delivery goes to each operator's own contact address
# (`acme-proxy admin user create --contact` / `admin user contact`), falling
# back to notify.email.to when an operator has none; notify.email.to may
# therefore be left empty here. Off (empty `enabled`) by default.
#   [admin.notify]
#   enabled = ["email"]              # ACME_PROXY_ADMIN__NOTIFY__ENABLED
#   [admin.notify.email]
#   smtp_host = "smtp.example.com"
#   from = "acme-proxy@example.com"
#   events = ["admin_sign_in", "admin_credential_changed"]
#                                    # ACME_PROXY_ADMIN__NOTIFY__EMAIL__EVENTS
#
# Set an operator's address with `acme-proxy admin user contact <name>
# --contact <address>`; startup warns (`admin_notify_contact_missing`) while
# this section is enabled and some operator has none, since their messages then
# fall back to notify.email.to — or nowhere, if that is empty too.

# [admin.filter] — who may reach this listener at all, for a deployment that
# cannot firewall the port on the host (a container). The same keys and check
# syntax as [filter] below, under ACME_PROXY_ADMIN__FILTER__…, evaluated on every
# admin request, /health included. Its own section: nothing is inherited from
# [filter]. Only allowed_ip, path, reverse_dns and custom checks mean anything
# here; the others are refused at startup. Empty rules (the default) filters
# nothing. trusted_proxies is also the only list the sign-in rate limiter
# believes X-Forwarded-For from.
#   [admin.filter]
#   rules = ["mgmt"]                 # ACME_PROXY_ADMIN__FILTER__RULES
#   trusted_proxies = []             # ACME_PROXY_ADMIN__FILTER__TRUSTED_PROXIES
#   [admin.filter.check.mgmt-net]
#   type  = "allowed_ip"
#   allow = ["10.20.0.0/24", "127.0.0.1/32"]
#   [admin.filter.rule.mgmt]
#   when = "mgmt-net"
#   then = "allow"

[admin.tls]
# HTTPS on admin.bind_address instead of cleartext — the same one-listener-not-
# two shape as [server.tls], and the same load-or-generate provisioning. Anything
# but http://localhost needs this on, or the browser will not store the session
# cookie at all (see admin.bind_address above).
enabled = false                     # ACME_PROXY_ADMIN__TLS__ENABLED
# Certificate chain (leaf first) and its private key, both PEM. When either is
# missing, a self-signed certificate for the host of admin.base_url is generated
# and written on startup. Separate paths from [server.tls] on purpose: the two
# listeners answer to different names and should not share a certificate by
# accident.
cert_path = "admin.pem"             # ACME_PROXY_ADMIN__TLS__CERT_PATH
key_path  = "admin.key"             # ACME_PROXY_ADMIN__TLS__KEY_PATH
# Budget for one TLS handshake, as [server.tls].
handshake_timeout_ms = 10000        # ACME_PROXY_ADMIN__TLS__HANDSHAKE_TIMEOUT_MS

[nonce]
# Lifetime of nonces in seconds. Shorter TTLs improve security against replay
# attacks, while longer TTLs provide more leeway for slow clients.
ttl_seconds = 300                   # ACME_PROXY_NONCE__TTL_SECONDS

[audit]
# Traceability and the CA's audit trail.
#
# There is no `enabled` key: the accounts/orders address columns and the
# audit_log table are always written. Recording who asked the CA to sign
# something is what a CA does, not a feature to switch on.
#
# Resolve a PTR record for the client's address and freeze it into the row
# alongside the address. Turn this off on an estate with no usable reverse
# zone — every lookup would fail, every *_ptr column would end up NULL anyway,
# and all that would be left is the round trip.
reverse_dns = true                  # ACME_PROXY_AUDIT__REVERSE_DNS
# Budget for one PTR lookup. Deliberately small: this runs inside a request
# that has already done its real work, so a slow nameserver must cost a NULL in
# one column, never latency on issuance.
reverse_dns_timeout_ms = 2000       # ACME_PROXY_AUDIT__REVERSE_DNS_TIMEOUT_MS
# Delete audit_log rows older than this many days. 0 keeps everything for ever,
# which is the right default for a trail whose value is that it is complete;
# set it where a retention policy says otherwise. `acme-proxy audit cleanup
# --older-than <days>` is the same sweep run by hand.
retention_days = 0                  # ACME_PROXY_AUDIT__RETENTION_DAYS

[jobs]
# The durable background-work queue: the `jobs` table plus the one runner that
# drains it. This is how the server finishes work it has already promised a
# client — an order answered `processing` is owed a certificate — so there is no
# `enabled` key. What is tunable is how hard and how long it tries.
#
# How often the runner looks for work nobody woke it for. This bounds only
# *scheduled* work: a backoff coming due, a periodic sweep firing. An enqueue
# wakes the runner directly, so a relay queued by finalize starts immediately
# whatever this says.
poll_interval_ms = 1000             # ACME_PROXY_JOBS__POLL_INTERVAL_MS
# How many jobs may run at once. A restart after an upstream outage that left a
# few thousand orders in flight would otherwise become a few thousand concurrent
# pollers against one CA, which is how a recoverable backlog turns into a
# rate-limit ban.
max_concurrent = 8                  # ACME_PROXY_JOBS__MAX_CONCURRENT
# How many attempts a job gets before it is retired permanently. Counted when
# the job is claimed, so one that kills the process still exhausts its budget
# instead of crash-looping. With the base below, five attempts span roughly
# seven and a half minutes. Unlike the other keys here this one is frozen onto
# each row as it is queued, so a change applies to new work rather than to a
# backlog already waiting.
max_attempts = 5                    # ACME_PROXY_JOBS__MAX_ATTEMPTS
# The first retry delay; each subsequent one doubles, up to the ceiling below.
retry_base_seconds = 30             # ACME_PROXY_JOBS__RETRY_BASE_SECONDS
# Where the doubling stops. An hour sits well under the default order lifetime,
# which keeps a job's own deadline the binding constraint rather than this.
retry_max_seconds = 3600            # ACME_PROXY_JOBS__RETRY_MAX_SECONDS
# The default budget for one attempt, and therefore how long a claim is held
# before another runner may take the row. A handler needing a different one says
# so itself; the relay asks for signer.relay.poll_timeout_secs.
lease_seconds = 300                 # ACME_PROXY_JOBS__LEASE_SECONDS
# Delete settled job rows older than this many days. Unlike audit.retention_days
# this defaults to non-zero: a finished job is a receipt, not evidence, and the
# trail that has to be complete is audit_log's. 0 keeps everything for ever and
# stops the sweep being scheduled at all.
retention_days = 7                  # ACME_PROXY_JOBS__RETENTION_DAYS

[metrics]
# The Prometheus exposition, on a listener of its own -- a third socket, not a
# route on the ACME or admin one. That is what makes the port the access
# control: a scrape carries no session and needs none, because reaching the port
# at all is the permission. Firewall it to your Prometheus host.
#
# Off by default: a certificate authority should not open a new socket because
# somebody upgraded it.
enabled = false                     # ACME_PROXY_METRICS__ENABLED
# Beside the other two -- server on 3000, admin on 3001, this on 3002. Unlike
# admin.bind_address, a non-loopback value is neither refused nor warned about:
# there is no cookie here, and a port reachable from a Prometheus host on
# another machine is the intended deployment. Startup refuses a value equal to
# server.bind_address or admin.bind_address, since only one could then bind.
bind_address = "127.0.0.1:3002"     # ACME_PROXY_METRICS__BIND_ADDRESS

[logging]
# This section is the SERVER's log stream. The admin subcommands emit nothing at
# all unless asked, with `--log-level <level>` or a non-empty RUST_LOG, and what
# they then emit goes to stderr so stdout stays what a script parses.
#
# Log level filtering (EnvFilter format). Last of three layers: a `--log-level`
# on the command line outranks RUST_LOG, which outranks this.
filter = "acme_proxy=info"  # ACME_PROXY_LOGGING__FILTER
# Enable structured JSON logging for integration with centralized log aggregators.
json_format = false                 # ACME_PROXY_LOGGING__JSON_FORMAT
# Where records are written: "stdout" or "stderr". Anything else fails at startup.
target = "stdout"                   # ACME_PROXY_LOGGING__TARGET
# ANSI colour in the human-readable format. Ignored when json_format is on.
ansi = true                         # ACME_PROXY_LOGGING__ANSI
# Span lifecycle records: "none", "close" or "full". "close" emits one record per
# span as it ends, carrying the time spent busy and idle inside it.
span_events = "none"                # ACME_PROXY_LOGGING__SPAN_EVENTS
# JSON only: lift a record's own fields to the top level instead of nesting them
# under "fields". What most log pipelines want, at the risk of colliding with the
# format's reserved keys.
flatten_event = false               # ACME_PROXY_LOGGING__FLATTEN_EVENT

[order]   # [per-profile] — a [profiles.<name>] block may override these
# Validity window for ACME order objects in seconds (RFC 8555 §7.1.3).
# This defines the order's lifetime, not the resulting certificate's validity.
validity_seconds = 604800           # ACME_PROXY_ORDER__VALIDITY_SECONDS
# Most identifiers one newOrder may name. The only other bound is
# server.max_body_bytes, which at ~30 bytes per identifier admits some four
# thousand names in one request -- and each becomes an authorization plus a
# challenge per offered type, inserted in ONE transaction, which SQLite's single
# writer makes a stall for every other write in the process. 100 is what Let's
# Encrypt allows and is far above what a real client asks for; a request past it
# is refused as `malformed`.
max_identifiers = 100               # ACME_PROXY_ORDER__MAX_IDENTIFIERS
# Days an order is kept AFTER it expires, before the daily retention sweep
# deletes it and cascades to its authorizations and challenges. 0 keeps
# everything for ever.
#
# A `valid` order is NEVER swept, whatever its age: its row is how revokeCert
# and the CRL find a certificate by serial, and what RFC 9773 renewal
# information is derived from. Only orders that ended some other way -- invalid,
# or abandoned pending/ready/processing -- are eligible, and only once their own
# `expires` is this many days behind, by which point no client can act on them.
retention_days = 30                 # ACME_PROXY_ORDER__RETENTION_DAYS

[signer]   # [per-profile] — a [profiles.<name>] block may override these
# The issuance backend to use.
#   "local_ca" — this server IS the CA: it signs every certificate itself.
#   "relay"    — this server RELAYS to an upstream ACME server, becoming a
#                client of it. Clients still prove domain control to *this*
#                server exactly as before; only the signing moves.
#   "custom"   — this server delegates issuance/revocation to an external
#                script (see [signer.custom] below) — an HSM tool, an
#                internal PKI CLI, a cloud KMS wrapper, anything.
backend = "local_ca"                # ACME_PROXY_SIGNER__BACKEND

[signer.local_ca]
# Configuration for the persistent Local CA.
# On first run, it automatically generates a self-signed CA if files are missing.
cert_path = "ca.pem"                # ACME_PROXY_SIGNER__LOCAL_CA__CERT_PATH
key_path  = "ca.key"                # ACME_PROXY_SIGNER__LOCAL_CA__KEY_PATH
# Key algorithm for the CA (e.g., "ecdsa-p256").
key_type  = "ecdsa-p256"            # ACME_PROXY_SIGNER__LOCAL_CA__KEY_TYPE
# The validity period of issued LEAF certificates in days.
# Distinct from the order's lifetime defined in [order].
leaf_validity_days = 90             # ACME_PROXY_SIGNER__LOCAL_CA__LEAF_VALIDITY_DAYS
# Where the CA's certificate revocation list (RFC 5280) is written; served at
# GET /crl and regenerated on every revocation and on every startup.
crl_path = "ca.crl"                 # ACME_PROXY_SIGNER__LOCAL_CA__CRL_PATH
# Where a relying party can FETCH that CRL, written into every issued leaf as
# cRLDistributionPoints (RFC 5280 §4.2.1.13). Empty emits no extension, which
# is why a leaf says nothing about revocation until this is set.
#
# Nothing derives it: the URL is frozen into every certificate signed while it
# is set, and this server's own CRL lives at {base_url}/profile/<name>/crl
# BEHIND that profile's filter chain — an address-based policy would refuse it
# to exactly the relying parties meant to read it. Name a URL you know is
# publicly reachable (a webroot or CDN copy of crl_path is the usual answer).
# Several entries mean one CRL reachable at several places, not several CRLs.
# http:// is fine and idiomatic here: fetching a signed CRL over TLS invites a
# validation loop. Two profiles sharing one CA share this list too.
crl_distribution_points = []        # ACME_PROXY_SIGNER__LOCAL_CA__CRL_DISTRIBUTION_POINTS
# Where a relying party can fetch THIS CA's own certificate, written into every
# issued leaf as authorityInfoAccess / caIssuers (RFC 5280 §4.2.2.1). Empty
# emits no extension. No OCSP pointer is ever written — this server runs no
# responder.
ca_issuer_urls = []                 # ACME_PROXY_SIGNER__LOCAL_CA__CA_ISSUER_URLS
# Where the ISSUING PRIVATE KEY lives.
#   "file"   — key_path above, loaded or generated. The default, and the only
#              behaviour that existed before this key.
#   "pkcs11" — a hardware token (YubiKey, HSM, SoftHSM2): the key is created
#              inside it and can never be read out; this server sends bytes to
#              be signed and gets a signature back. See
#              [signer.local_ca.pkcs11] below. Requires a binary built with
#              `--features hsm`; configuring it on one without is a startup
#              error, never a silent fallback to the file key.
# Anything else is a startup error.
key_source = "file"                 # ACME_PROXY_SIGNER__LOCAL_CA__KEY_SOURCE

[signer.local_ca.subject]
# Overrides for the autogenerated CA's X.509 Subject (Distinguished Name).
# Every key here is optional; an unset (or empty-string) value is omitted
# from the Subject, except common_name, which falls back to
# "acme-proxy local CA" so the CA always carries one.
#
# Unset by default: the CA's Subject is CommonName-only, exactly as before
# this table existed.
# common_name         = "acme-proxy local CA"   # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COMMON_NAME
# organization        = "Example Corp"          # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATION
# organizational_unit = "IT"                    # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATIONAL_UNIT
# country             = "US"                    # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COUNTRY
# state               = "California"            # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__STATE
# locality            = "San Francisco"         # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__LOCALITY

[signer.local_ca.pkcs11]
# Only read when key_source = "pkcs11" (above), and only available in a build
# with `--features hsm`.
#
# TWO RULES DIFFER FROM THE FILE PATH, both fatal at startup:
#
#   1. The CA is NEVER generated. The private key is created inside the token by
#      its own tooling (pkcs11-tool --keypairgen, yubico-piv-tool -a generate),
#      which this server cannot do for it, so cert_path must ALREADY hold a
#      certificate for that key. key_path is neither read nor written.
#   2. The token key and cert_path are cross-checked. A mismatch — nearly always
#      a wrong key_label — stops the server, rather than issuing a fleet of
#      certificates that verify nowhere. This is stricter than key_source =
#      "file", where nothing checks that key_path matches cert_path.
#
# key_type above is ignored here: nothing is generated, so the algorithm is read
# off the token (P-256 and P-384 are supported).
#
# The module is dlopen'd at runtime, so nothing below affects the build.
#
# module_path  = "/usr/lib/softhsm/libsofthsm2.so"  # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__MODULE_PATH
#                "/usr/lib/libykcs11.so" for a YubiKey (Debian puts it under
#                /usr/lib/x86_64-linux-gnu/). Required.
# Which token, by label. PREFER THIS over slot_id: slot numbers are assigned
# dynamically and change across reboots and re-plugs (SoftHSM2 will hand you
# something like 276468771).
# token_label  = "acme-ca"          # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__TOKEN_LABEL
# Which slot, for a token with no usable label. Read only when token_label is
# empty.
# slot_id      = 0                  # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__SLOT_ID
# The private key's CKA_LABEL. Required. On a YubiKey these are FIXED by the
# driver — slot 9c is "Private key for Digital Signature" — so this is looked
# up with `pkcs11-tool --module <module> --list-objects --login`, not chosen.
# key_label    = "ca-key"           # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__KEY_LABEL
# The key's CKA_ID as hex, to disambiguate a token holding several keys under
# one label. Optional; two matching keys and no key_id is a startup error rather
# than a coin flip over which one signs.
# key_id       = "01"               # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__KEY_ID
# A file holding the user PIN, trailing whitespace trimmed (so a PIN written
# with `echo` works). Warns if world-readable, like ca.key does. This is the
# preferred way to supply it.
# pin_file     = "/etc/acme-proxy/hsm.pin"  # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN_FILE
# SENSITIVE — the PIN directly. Prefer pin_file or the environment variable; a
# PIN here is a long-lived secret in a file that gets copied around. pin_file
# wins when both are set, and neither set is a startup error.
#
# NOTE: a token blocks after a few wrong attempts (three on a YubiKey PIV
# applet, then you need the PUK). That is why a failed signature is retried at
# most ONCE, and why a stray character in pin_file is worth checking for before
# restarting repeatedly.
# pin          = ""                 # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN

[signer.relay]
# Only read when backend = "relay" (above).
#
# The upstream ACME server's directory URL — another acme-proxy, a private
# enterprise CA, or a public CA. Required for this backend; an empty value is a
# startup error.
directory_url = ""                  # ACME_PROXY_SIGNER__RELAY__DIRECTORY_URL
# This proxy's own ACME account key AT the upstream (ECDSA P-256, generated 0600
# if the file is absent). The account URL the upstream assigns is stored beside
# it, in the same path with the extension replaced by ".kid" — so only the FIRST
# startup contacts the upstream to register; later ones read both files locally.
account_key_path = "upstream_account.key"  # ACME_PROXY_SIGNER__RELAY__ACCOUNT_KEY_PATH
# Optional contacts sent with the upstream newAccount registration.
contact = []                        # ACME_PROXY_SIGNER__RELAY__CONTACT
# How this proxy satisfies the UPSTREAM's own domain-control check. This is a
# second, independent proof cycle: the upstream account belongs to this server,
# so the original client cannot answer it.
#   "bypass" — the upstream validates nothing (a private CA that already trusts
#              this server, or another acme-proxy with challenge.bypass = true).
#   "dns01"  — publish the TXT record the upstream asks for, which is what a
#              public CA requires. This is the ONE place the server writes DNS
#              rather than reading it: the key authorization the upstream
#              expects is derived from THIS proxy's account there, so the
#              original client cannot answer it even in principle.
#   "http01" — serve the file the upstream asks for, from this server's own
#              root router at /.well-known/acme-challenge/<token>. This server
#              does NOT open a second listener: a reverse proxy must forward or
#              redirect that path here from port 80 of every name being issued
#              (RFC 8555 §8.3 permits a redirect, so the target need not share
#              the name — one `return 301` is enough). There is nothing else to
#              configure, hence no [signer.relay.http01] table. Cannot prove a
#              wildcard, since nothing answers on the name "*.example.com" —
#              use "dns01" for those.
challenge_strategy = "bypass"       # ACME_PROXY_SIGNER__RELAY__CHALLENGE_STRATEGY
# How often to poll the upstream order while it resolves. The upstream's own
# Retry-After takes precedence when it sends one.
poll_interval_ms = 2000             # ACME_PROXY_SIGNER__RELAY__POLL_INTERVAL_MS
# Total budget for one upstream issuance. On expiry the local order is marked
# invalid with an explicit timeout reason, never left processing forever.
poll_timeout_secs = 300             # ACME_PROXY_SIGNER__RELAY__POLL_TIMEOUT_SECS

[signer.relay.eab]
# This proxy's own upstream External Account Binding credential (RFC 8555
# §7.3.4), as an alternative to the one-shot
# `acme-proxy upstream register --eab-kid <kid>` command. Both are the SAME
# credential: it authorizes exactly one newAccount call and is useless
# afterwards, since registration itself only ever runs once (guarded by the
# .kid sidecar next to account_key_path).
#
# Putting it here trades away the property that made the admin command the
# only path — a bootstrap secret living in configuration for the life of the
# server — for not needing a separate imperative step, which matters when
# config.toml is already populated by a secrets manager or a templated
# deployment. Once registration succeeds, `serve` logs a
# signer_relay_eab_secret_in_config warning on EVERY startup for as long as
# hmac_key stays non-empty — the same treatment challenge.bypass and
# ipam.netbox.insecure_skip_verify get — so clear it out once
# `acme-proxy upstream show` confirms a kid is stored.
#
# Both empty (the default) is unchanged from before this table existed:
# `serve` then requires `acme-proxy upstream register` if the upstream
# demands EAB. Either field set without the other is a startup error.
kid = ""                            # ACME_PROXY_SIGNER__RELAY__EAB__KID
# SENSITIVE — prefer the environment variable to a file on disk, like every
# other secret in this file. Base64: url-safe, unpadded url-safe, or standard
# (the same three forms the admin command accepts) — a value that decodes as
# none of them is a startup error.
hmac_key = ""                       # ACME_PROXY_SIGNER__RELAY__EAB__HMAC_KEY

[signer.relay.dns01]
# Only read when challenge_strategy = "dns01". The only provider is "rfc2136",
# which works with any authoritative server implementing RFC 2136 dynamic
# update (BIND, PowerDNS, Knot, CoreDNS with the `update` plugin) rather than
# tying the server to one cloud vendor's API.
provider = "rfc2136"                # ACME_PROXY_SIGNER__RELAY__DNS01__PROVIDER
# DNS alias mode: publish every challenge record at _acme-challenge.<alias>
# instead of _acme-challenge.<domain>, after CNAMEing each domain's
# _acme-challenge name there once. The alias must lie inside rfc2136.zone.
# Empty (the default) writes in each domain's own zone.
challenge_alias = ""                # ACME_PROXY_SIGNER__RELAY__DNS01__CHALLENGE_ALIAS

[signer.relay.dns01.rfc2136]
# host:port of the authoritative server that accepts the update.
server = ""                         # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__SERVER
# The zone being updated, e.g. "example.org." (trailing dot).
zone = ""                           # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__ZONE
tsig_key_name = ""                  # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_NAME
# SENSITIVE — but unlike the EAB credential this one is long-lived, since every
# update needs it, so it legitimately lives in configuration. Prefer the
# environment variable to a file on disk. Standard base64, as `dnssec-keygen`
# and BIND emit it — note this is NOT base64url, unlike the EAB secret.
tsig_key_secret = ""                # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_SECRET
# hmac-sha256 (default), hmac-sha384 or hmac-sha512. HMAC-MD5 is deliberately
# not offered.
tsig_algorithm = "hmac-sha256"      # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_ALGORITHM

[signer.relay.dns01.propagation]
# What to wait for between publishing the record and asking the upstream to
# validate it. The upstream looks once: a record it cannot see yet makes the
# authorization invalid for good, and the client's order fails with it.
#   "none"  — trigger right after the update (the default). Right when the
#             update server is itself what the CA asks.
#   "delay" — sleep delay_secs first. For a provider that accepts an update
#             before serving it (an API behind an RFC 2136 bridge), or
#             secondaries that lag the primary.
mode = "none"                       # ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__MODE
# Only read under mode = "delay". Must be less than poll_timeout_secs, which
# bounds the whole attempt — and the delay runs once PER NAME in the order, so
# raise poll_timeout_secs for a multi-name order.
delay_secs = 30                     # ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__DELAY_SECS

[signer.custom]
# Only read when backend = "custom" (above). Delegates issuance and
# revocation to an external script — an HSM tool, an internal PKI CLI, a
# cloud KMS wrapper, anything that can read a CSR and hand back a chain.
#
# An empty script_path is a startup error, the same as a `custom` filter check.
script_path = ""                    # ACME_PROXY_SIGNER__CUSTOM__SCRIPT_PATH
timeout_ms = 5000                   # ACME_PROXY_SIGNER__CUSTOM__TIMEOUT_MS
args = []                           # ACME_PROXY_SIGNER__CUSTOM__ARGS
#
# Whether the script also answers the "crl" hook (GET /crl) and the
# "renewal_info" hook (RFC 9773 ARI). Both default to false: an unset script
# has nothing useful to say here, and the trait's own defaults already cover
# it sensibly — no CRL published at this endpoint, and "no opinion, compute
# renewal locally" respectively. Turn either on only once the script actually
# implements that hook.
supports_crl = false                 # ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_CRL
supports_renewal_info = false        # ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_RENEWAL_INFO
#
# What the server has already checked before the "issue" hook runs, so the
# script does not have to and must not undo it: the CSR parses, its
# self-signature verifies, every SAN is a DNS name, that set of DNS names is
# exactly ACME_SIGNER_IDENTIFIERS, and the subject common name — if it looks
# like a host name at all — is one of them. That is RFC 8555 §7.4's "the exact
# same set of requested identifiers", and it is the invariant that makes an
# authorization mean anything: the account proved control of those names and no
# others. The script must therefore sign the names in the CSR as they stand and
# must not add names of its own; a certificate for anything else is one this
# server's clients never proved they were entitled to.
#
# The script is invoked once per hook per request — never for a hook whose
# `supports_*` flag is off. It receives context via environment variables:
#   ACME_SIGNER_HOOK        - "issue", "revoke", "crl" or "renewal_info"
#   ACME_SIGNER_ORDER_ID    - the local order id (issue hook only)
#   ACME_SIGNER_IDENTIFIERS - comma-separated identifier values (issue hook only)
#   ACME_SIGNER_REASON      - the RFC 5280 revocation reason code, or empty (revoke hook only)
#
# A JSON payload with the full context — including the binary CSR/certificate,
# standard base64 encoded (NOT base64url, unlike ACME's own fields) — is
# always piped to stdin, one object per hook:
#   issue:         {"hook":"issue","order_id":..,"identifiers":[{"type":"dns","value":".."}],"csr_der_base64":".."}
#   revoke:        {"hook":"revoke","cert_der_base64":"..","reason":<int|null>}
#   crl:           {"hook":"crl"}
#   renewal_info:  {"hook":"renewal_info","cert_der_base64":".."}
#
# Exit codes and stdout, per hook:
#   issue        - exit 0: stdout is the PEM certificate chain (leaf then
#                  issuer), used exactly as [signer.local_ca] would produce
#                  it. Exit 3 is reserved to mean "bad CSR" (maps to a 400
#                  the client can act on) — deliberately not exit 1, which is
#                  what a script under `set -e` produces for an unrelated
#                  bug, and mapping that to a 400 would hide a real failure
#                  behind a client-facing error instead of a 500. Any other
#                  non-zero exit is an internal failure (500); the order is
#                  marked invalid, matching every other backend's BadCsr-vs-
#                  Internal split. This backend always answers synchronously
#                  — there is no "processing" state, since a shelled-out
#                  script has no way to call back into this server later.
#   revoke       - exit 0 succeeds; the script is responsible for idempotency
#                  (revoking an already-revoked certificate must not fail),
#                  the same contract every signer backend has to meet. Any
#                  non-zero exit is an internal failure (500).
#   crl          - exit 0: stdout is the DER-encoded CRL, raw bytes, no
#                  encoding. Empty stdout on exit 0, or any non-zero exit,
#                  means "no CRL right now" (GET /crl answers 404); a script
#                  failure here is logged but never fails the request that
#                  triggered it, since GET /crl is otherwise unauthenticated
#                  and doesn't gate issuance.
#   renewal_info - exit 0 with blank stdout means "no opinion" (falls back to
#                  this server's local estimate); exit 0 with stdout
#                  "<start_epoch> <end_epoch>" (whitespace-separated
#                  integers) gives an explicit renewal window. An optional
#                  third token is RFC 9773 §4.2's `explanationURL`, served
#                  verbatim to the client — a page saying why the window has
#                  that value, e.g. during a mass-revocation event. Anything
#                  else, or a non-zero exit, is an internal failure.
#
# The script does NOT inherit the server's environment: it is started from an
# empty one holding only the ACME_SIGNER_* variables above plus a minimal PATH
# (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). This matters
# even more here than for a `custom` filter check — the RFC 2136 TSIG secret
# used by signer.relay.dns01 lives inside this very [signer] section tree, and
# a custom signer script has no business reading it.
#
# One process is spawned per invocation. A script still running when
# timeout_ms expires is killed, not left behind.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. backend = "local_ca" and script_path = ""):
#
# [signer.custom]
# script_path = "/etc/acme-proxy/signer/issue.sh"
# timeout_ms = 5000
# args = []
# supports_crl = true
# supports_renewal_info = false

[challenge]   # [per-profile] — a [profiles.<name>] block may override these
# Which challenge types each new authorization offers, in the order they appear
# in the authorization object. The client picks one of them to satisfy.
# Known names: "http-01", "dns-01", "tls-alpn-01".
#
# A WILDCARD identifier ("*.example.com") can only be proved with "dns-01"
# (RFC 8555 §8.4), so a wildcard authorization offers dns-01 alone — and newOrder
# refuses a wildcard outright when dns-01 is not listed here.
#
# An empty list is a startup error: authorizations would carry no challenges, so
# no client could ever prove control of a name.
#
# Note the spelling: the list uses the RFC names ("http-01"), while the per-type
# tables below use them as TOML keys ("http_01").
enabled = ["http-01"]               # ACME_PROXY_CHALLENGE__ENABLED
# Skip validation entirely: triggering a challenge marks it, and its
# authorization, valid with NO network check.
#
# With it on, anyone who can reach this server can obtain a certificate for any
# name, and the [filter] section below is the only access control. The server
# logs a `challenge_validation_bypassed` warning at startup saying so.
#
# This used to default to true — it was the behaviour predating real validation,
# and the default was kept so an existing deployment survived that upgrade. It
# now defaults to FALSE: combined with an empty filter.rules and a default bind
# on every interface, the old default meant a server started with no
# configuration at all was an open certificate authority. Turning validation off
# is a legitimate thing to want (a private CA behind a filter that already knows
# who may ask for what), but it is worth having to ask for.
#
# UPGRADING: a deployment that relied on the old default must now set this
# explicitly, or every challenge will be really validated and issuance will
# start failing for names this server cannot reach.
bypass = false                      # ACME_PROXY_CHALLENGE__BYPASS
# Budget for one validation attempt. The trigger queues the work and answers
# `processing`, so this bounds a job attempt in the runner rather than a request.
timeout_ms = 5000                   # ACME_PROXY_CHALLENGE__TIMEOUT_MS
# How many of one account's challenges may be validating at once; 0 is no limit.
# A trigger over the cap is answered 429 rateLimited with a Retry-After, and the
# challenge stays pending, so the client simply asks again.
max_in_flight_per_account = 32      # ACME_PROXY_CHALLENGE__MAX_IN_FLIGHT_PER_ACCOUNT

[challenge.http_01]
# GET http://<identifier>:<port>/.well-known/acme-challenge/<token>, whose body
# must be the key authorization (RFC 8555 §8.3). Leading and trailing whitespace
# is ignored; anything else must match exactly.
port = 80                           # ACME_PROXY_CHALLENGE__HTTP_01__PORT
# Port a redirect may point at when it switches to https. The responder's
# certificate is NOT validated — RFC 8555 §8.3 says so explicitly, because the
# proof is the body, not the transport.
https_port = 443                    # ACME_PROXY_CHALLENGE__HTTP_01__HTTPS_PORT
# Follow 3xx responses. Redirecting :80 to https is the most common web server
# configuration, so this is on by default.
#
# It does mean the server issues requests wherever a client points a Location
# header. Three things keep that contained: only http/https and only the two
# ports above are followed, the whole chain shares timeout_ms, and a mismatching
# response body is NEVER echoed back to the client — so a redirect cannot be used
# to read internal pages. On a network where even the request is unwelcome, set
# this to false.
follow_redirects = true             # ACME_PROXY_CHALLENGE__HTTP_01__FOLLOW_REDIRECTS
max_redirects = 5                   # ACME_PROXY_CHALLENGE__HTTP_01__MAX_REDIRECTS
# Bodies larger than this are refused WITHOUT being compared: the key
# authorization is under a hundred bytes and nothing else belongs at that URL.
max_response_bytes = 4096           # ACME_PROXY_CHALLENGE__HTTP_01__MAX_RESPONSE_BYTES

# "dns-01" has no settings of its own. It looks up TXT records at
# _acme-challenge.<identifier> through the system resolver, with caching disabled
# so a record the client has just published is not masked by a cached negative
# answer. The value must be base64url(SHA256(key authorization)) — the digest,
# not the key authorization itself, which is what http-01 serves.

[challenge.tls_alpn_01]
# TLS handshake with SNI = <identifier> and ALPN "acme-tls/1"; the certificate
# presented must carry exactly one dNSName (the identifier) and the critical
# id-pe-acmeIdentifier extension holding SHA-256 of the key authorization
# (RFC 8737). No separate listener is needed — the responder is the TLS server
# that is already there.
port = 443                          # ACME_PROXY_CHALLENGE__TLS_ALPN_01__PORT

[filter]   # [per-profile] — a [profiles.<name>] block may override these
# Request filtering: which clients may reach the server, and which names they
# may have certified. Independent of [challenge] above, and the only access
# control left when challenge.bypass is on — with it set, anyone who can reach
# this server can obtain a certificate for any name.
#
# The shape is a small policy engine: NAMED CHECKS, and RULES over them.
#
#   - a [filter.check.<name>] is one question about a request — "is this
#     address in the management network?", "does the inventory say this address
#     owns this name?" — with a `type` saying which question.
#   - a [filter.rule.<name>] is a boolean expression over check names plus what
#     a match means: `then = "allow"` or `then = "deny"`.
#   - `rules` below lists the rules to evaluate, IN ORDER. First match wins.
#
# Everything is a named check, `custom` included, and two checks of one type
# are ordinary — two identifier lists, two script hooks, an address list per
# network. See doc/src/filters/ for the whole chapter.

# Which [filter.rule.<name>] entries to evaluate, and in what order.
# Empty (the default) disables filtering entirely, and the server logs a
# `filter_disabled` warning at startup saying so.
rules = []                          # ACME_PROXY_FILTER__RULES
# What happens at a stage where a rule was applicable and none of them matched.
# "deny" or "allow".
#
# Never consulted at a stage no rule applies to, which matters more than it
# sounds: a policy made entirely of identifier-stage rules must not refuse every
# connection before a name has even been mentioned. So a policy of nothing but
# `identifiers` checks still serves /directory and /newNonce.
default = "deny"                    # ACME_PROXY_FILTER__DEFAULT
# CIDRs of reverse proxies whose forwarded-for header is believed. Leave empty
# when clients connect directly: the header is then ignored, and a client cannot
# claim an allowlisted address by setting it itself.
trusted_proxies = []                # ACME_PROXY_FILTER__TRUSTED_PROXIES
# Header carrying the original client address, read only from a trusted proxy.
forwarded_header = "x-forwarded-for"  # ACME_PROXY_FILTER__FORWARDED_HEADER

# --------------------------------------------------------------------- checks
#
# Each [filter.check.<name>] takes a `type` plus that type's own keys. The
# available types are:
#
#   allowed_ip    which networks an address may be in         (CIDR lists)
#   reverse_dns   the address must have a usable PTR record
#   identifiers   which names may be certified
#   eab           which EAB credential the account registered under
#   ipam          ask the [ipam] inventory whether this address owns this name
#   custom        shell out to an operator-supplied script
#
# Each name must match ^[a-z0-9-]+$ — lowercase letters, digits and `-`, the
# same restriction profile names have. Reason: a name is also an environment
# variable segment (ACME_PROXY_FILTER__CHECK__<NAME>__...), and the config crate
# lowercases those, so e.g. "MgmtNet" in this file and
# "ACME_PROXY_FILTER__CHECK__MGMTNET__..." would silently become TWO different
# entries instead of one overriding the other. `and`, `or` and `not` are the
# condition language's own words and cannot name a check.
#
# A check defined here but named by no selected rule is never built: it opens no
# connection, validates nothing, and is reported once at startup as
# `filter_check_unused`. That is deliberate — a global [filter] section can hold
# a library of checks and each profile pick the subset its `rules` names.
#
# Setting a key that belongs to another type is a STARTUP ERROR naming both, so
# `script_path` on an `allowed_ip` check stops the server rather than being read
# by nothing.
#
# WHERE A CHECK DECIDES. There are two hook points: the connection (before the
# handler, address only) and the identifiers (at newOrder, and again at finalize
# against the CSR). `allowed_ip` and `custom` answer at both; `identifiers` and
# `ipam` only once names are known; `reverse_dns` at the connection, because a
# PTR plus forward-confirmation exchange at newOrder AND finalize triples the
# lookups for an answer that has not changed. Override with `stages`, e.g.
# stages = ["identifiers"] on a reverse_dns check. Naming a stage the type
# cannot serve is a startup error.
#
# A RULE runs at the INTERSECTION of its checks' stages — never the union,
# because evaluating a rule where one of its checks cannot run would silently
# treat that check as passing. A rule combining a connection-only check with an
# identifiers-only one therefore has no stage at all, and is a startup error
# naming both sides rather than a rule that quietly never fires.
#
# GLOBS AND REGEXES. `allow`/`deny` on the name-matching checks take globs,
# where `*` matches ONE label: "*.example.com" matches "a.example.com", not
# "a.b.example.com" and not "example.com" — list the bare name too, exactly as
# in a certificate. Everything else in a glob is literal. `allow_regex` and
# `deny_regex` take regexes instead, anchored automatically as ^(?:...)$, and
# are unioned with the globs. `deny` wins over `allow`, and an empty `allow`
# imposes no constraint, which gives three usable shapes: allow-only (a strict
# allowlist), deny-only (a blocklist, everything else served), or both (an
# allowlist with holes punched in it).
#
# On `allowed_ip`, `allow`/`deny` are CIDRs or bare addresses instead. Deny-wins
# there is plain membership, not longest-prefix-match: a /32 in `allow` does not
# beat a /8 in `deny`.

# ---------------------------------------------------------------------- rules
#
# `when` is a boolean expression over check names: `and`, `or`, `not` and
# parentheses, with `not` binding tightest, then `and`, then `or`. A parse error
# names the column it gave up at.
#
# `message` replaces the failing check's own wording in the 403 the client sees,
# so an operator can say "ask the network team" instead of exposing which check
# bit. `mode = "warn"` makes a matching rule log `filter_rule_warned` and NOT
# decide, so a tightened policy can be watched in production before it bites —
# a policy of nothing but warn rules falls through to `default`.
#
# A rule whose condition could not be evaluated — an inventory outage, a DNS
# timeout — does not silently drop out. It is remembered, and if the answer the
# policy does reach differs from what that rule would have decided, the whole
# request is a retryable 500 rather than a refusal the client would believe.
# This is also why `mgmt-net or inventory` survives an inventory outage while
# `inventory` alone does not: an `or` whose other side already passed does not
# care what the unknown would have been.

# A worked example, commented out so this file still describes the
# all-defaults configuration. Turning it on needs `rules` above as well:
#
# rules = ["public", "mgmt-bypass", "inventory-owned"]
#
# # Relying parties fetching the CRL are not the ACME clients you allowlisted,
# # and /crl is served by the profile router — so an address-based policy
# # without this makes revocation checking fail for everyone outside it.
# [filter.check.public-paths]
# type  = "path"
# allow = ["/crl"]
#
# [filter.check.mgmt-net]
# type  = "allowed_ip"
# allow = ["10.0.0.0/8", "192.168.1.0/24"]
# deny  = ["10.1.2.0/24"]
#
# [filter.check.corp-names]
# type            = "identifiers"
# allow           = ["*.corp.example.com", "corp.example.com"]
# deny            = ["secret.corp.example.com"]
# allowed_types   = ["dns", "cn"]
# allow_wildcards = false
#
# [filter.check.inventory]
# type = "ipam"           # consults the [ipam] section below
#
# # Multi-tenancy: `acme-proxy eab create --label tenant-a` mints the credential
# # BEFORE any account exists, so the label is a handle you can write here up
# # front — unlike an account id, which is generated and only knowable after the
# # fact. require_active makes `eab revoke` reach accounts already registered.
# [filter.check.is-tenant-a]
# type           = "eab"
# allow          = ["tenant-a"]
# kids           = []
# require_active = false
#
# [filter.check.has-ptr]
# type                    = "reverse_dns"
# require_forward_confirm = true
# timeout_ms              = 2000
#
# [filter.check.check-network]
# type        = "custom"
# script_path = "/etc/acme-proxy/filters/check-network.sh"
# timeout_ms  = 5000
# pass_stdin  = true
# args        = []
#
# [filter.rule.public]
# when = "public-paths"
# then = "allow"
#
# [filter.rule.mgmt-bypass]
# when = "mgmt-net"
# then = "allow"
#
# [filter.rule.inventory-owned]
# when    = "corp-names and (inventory or mgmt-net)"
# then    = "allow"
# message = "this address owns no such name in the inventory"
# mode    = "enforce"
#
# Equivalent via environment variables:
#   ACME_PROXY_FILTER__RULES=public,mgmt-bypass,inventory-owned
#   ACME_PROXY_FILTER__CHECK__MGMT-NET__TYPE=allowed_ip
#   ACME_PROXY_FILTER__CHECK__MGMT-NET__ALLOW=10.0.0.0/8,192.168.1.0/24
#   ACME_PROXY_FILTER__RULE__MGMT-BYPASS__WHEN=mgmt-net
#   ACME_PROXY_FILTER__RULE__MGMT-BYPASS__THEN=allow
#
# THE CUSTOM SCRIPT CONTRACT. Each script receives context via environment
# variables:
#   ACME_FILTER_HOOK        - "connection" or "identifiers"
#   ACME_FILTER_CHECK_NAME  - which [filter.check.<name>] invoked it
#   ACME_FILTER_CLIENT_IP   - client IP address (or empty if unresolvable)
#   ACME_FILTER_METHOD      - HTTP method (for connection hook)
#   ACME_FILTER_PATH        - HTTP path (for connection hook)
#   ACME_FILTER_ACCOUNT_ID  - account ID (for identifiers hook)
#   ACME_FILTER_STAGE       - "newOrder" or "CSR" (for identifiers hook)
#   ACME_FILTER_IDENTIFIERS - comma-separated values (for identifiers hook)
#
# If pass_stdin is true, a JSON payload with the full context is also piped to
# stdin. Exit code 0 permits the request. Any non-zero code refuses it, using
# the first non-empty line of stdout (or stderr) as the detail message.
# Anything that stops the script answering at all — it could not be spawned, it
# timed out — is an *unknown* rather than a refusal, so a broken hook is a
# retryable 500 and never fails open.
#
# The script does NOT inherit the server's environment: it is started from an
# empty one holding only the ACME_FILTER_* variables above plus a minimal PATH
# (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). This server
# legitimately carries secrets in its own environment — configuration layers in
# from ACME_PROXY_* variables, which is where the RFC 2136 TSIG key may live —
# and a filter hook has no business reading them. A script needing a value of
# its own should read it from a file, or be given it through `args`.
#
# One process is spawned per custom check per hook invocation, i.e. per
# non-exempt request for the connection hook: keep each script cheap, and keep
# timeout_ms tight. A script still running when timeout_ms expires is killed,
# not left behind.

[ipam]   # [per-profile] — a [profiles.<name>] block may override these
# The IP address management inventory an `ipam` filter check consults.
#
# Where an `identifiers` check answers "may anyone here have this name?", an
# `ipam` check answers "may THIS address have THIS name?" — and reads the
# answer from the inventory an estate already keeps instead of a list
# maintained here. Nothing below is read unless a [filter.check.<name>] with
# type = "ipam" is named by a rule in filter.rules.
#
# Matching is EXACT, case-insensitively and ignoring a trailing dot. There is
# no suffix rule and no wildcard expansion: an entry `example.com` does not
# permit `a.example.com`, and a request for `*.example.com` needs that exact
# string in the inventory. Same reasoning as the anchored patterns of an
# `identifiers` check.
#
# An `ip` identifier is permitted when it is the connecting address itself, or
# when it is listed like any other name. A common name ("cn") is skipped, as by
# an `identifiers` check. Any other type is refused: an inventory has nothing to
# say about an email address or a URI, and this filter refuses what it cannot
# confirm.
#
# The inventory being unreachable, answering 5xx, refusing the token or timing
# out is NOT a denial: it is reported as a server error (500) the client can
# retry, so an outage stops issuance rather than permitting everything.
#
# Which product to ask: "netbox", "phpipam", "custom" (an operator script), or
# empty for none. Anything else is a startup error rather than a silent
# fallback. Enabling the `ipam` filter with this empty is also a startup error.
backend = ""                        # ACME_PROXY_IPAM__BACKEND
# Budget for one whole lookup, however many requests the backend makes to
# answer it — the address query plus, depending on sources, up to four more.
# Applied once around all of them, so a wedged inventory cannot pin a request
# open. Reported as a server error rather than a denial, since the server
# failed to decide. This runs inside newOrder and finalize, so it is also part
# of those requests' worst case.
timeout_ms = 5000                   # ACME_PROXY_IPAM__TIMEOUT_MS

[ipam.netbox]
# Where the names come from. Empty, or an unknown entry, is a startup error —
# an inventory trusted for nothing can never permit a name. Order is
# meaningless: the result is a union, unlike filter.rules' evaluation order.
#
#   dns_name      the address object's own dns_name
#   custom_field  the custom field named below, on the address object
#   device        the same field on the device or virtual machine the address
#                 is assigned to. A FALLBACK, not a union: read only when the
#                 address object itself carried no value, because a value set
#                 on the address is the more specific statement and narrowing
#                 one address of a machine must not be widened again by the
#                 machine-wide list.
#   vip           role-tagged service addresses of the same device (see
#                 vip_roles) — a VIP shared by a keepalived or CARP pair. A
#                 UNION: a member's own names and the names on the service
#                 address it answers for are both true at once. One extra
#                 query: ?device_id=N&role=...
#   fhrp          the addresses of an FHRP group the client's OWN INTERFACE is
#                 recorded as a member of. Also a union. Two extra queries, and
#                 the direction is the point: a group is only ever reached
#                 through an assignment naming this interface, so there is no
#                 way to reach a group the client is not in. An interface in no
#                 group contributes nothing.
#
# The last two are off by default: both widen what a client may certify.
sources = ["dns_name", "custom_field", "device"]  # ACME_PROXY_IPAM__NETBOX__SOURCES
# Base URL of the instance. Any path is kept, so a NetBox served under a
# subpath (https://example.com/netbox) works. Required when backend = "netbox".
url = ""                            # ACME_PROXY_IPAM__NETBOX__URL
# API token. A read-only one is enough, and either generation works: the scheme
# follows the token itself, so there is nothing here to switch. A v2 token —
# the default since NetBox 4.5 — is the whole `nbt_<key>.<secret>` string shown
# once at creation and is sent as `Authorization: Bearer ...`; a legacy v1 token
# is sent as `Authorization: Token ...` and stops being accepted in NetBox 4.7.
# Prefer the environment variable over writing it here: this is a secret, like
# [signer.relay.dns01.rfc2136]'s TSIG key.
token = ""                          # ACME_PROXY_IPAM__NETBOX__TOKEN
# Custom field holding the extra names an address may have certified. Configure
# it in NetBox as a multi-select or text field on ipam.ipaddress (and, for the
# `device` source, on dcim.device / virtualization.virtualmachine). A single
# string is accepted as well as a list.
custom_field = "acme_domains"        # ACME_PROXY_IPAM__NETBOX__CUSTOM_FIELD
# Which NetBox address roles mark a service address, for the `vip` source.
# Read only when `vip` is in sources, so this is *which* roles rather than
# whether to look at all. The role is re-checked on the answer as well as sent
# as a filter: a filter parameter this server got wrong must never degrade into
# "every address on the device".
vip_roles = ["vip", "vrrp", "hsrp", "glbp", "carp", "anycast"]  # ACME_PROXY_IPAM__NETBOX__VIP_ROLES
# Extra CA certificates (PEM) to trust on top of the public roots, for a NetBox
# behind an internal PKI. Ignored when insecure_skip_verify is on.
ca_cert_path = ""                   # ACME_PROXY_IPAM__NETBOX__CA_CERT_PATH
# Skip verification of NetBox's TLS certificate entirely.
#
# Meant as a temporary way out of an expired NetBox certificate, so that issuing
# certificates does not stop while that one is renewed. With it on, the answers
# this server trusts could come from anyone able to intercept the connection —
# unlike the challenge validators, where the certificate is deliberately not
# checked because the proof is elsewhere, here it is the only thing identifying
# the service deciding who may have a name certified. Startup logs an
# `ipam_netbox_tls_verification_disabled` warning for as long as it is set,
# the counterpart of `tls_disabled` and `challenge_validation_bypassed`.
insecure_skip_verify = false        # ACME_PROXY_IPAM__NETBOX__INSECURE_SKIP_VERIFY

[ipam.phpipam]
# The same three sources NetBox offers, read from phpIPAM's own shapes:
# dns_name is the address's `hostname`, custom_field is the column named below,
# and device follows `deviceId`. phpIPAM records no address roles and no
# redundancy groups, so naming `vip` or `fhrp` here is a startup error rather
# than a silently ignored setting.
sources = ["dns_name", "custom_field", "device"]  # ACME_PROXY_IPAM__PHPIPAM__SOURCES
# Base URL of the instance. Required when backend = "phpipam".
url = ""                            # ACME_PROXY_IPAM__PHPIPAM__URL
# The API application's identifier — the <app_id> in every phpIPAM API path,
# created under Administration -> API. One path segment: letters, digits, `-`
# and `_`.
app_id = "acme"                     # ACME_PROXY_IPAM__PHPIPAM__APP_ID
# The application's app code, sent as a bare `token` header (phpIPAM's own
# scheme, not `Authorization`). Set the application's security to "SSL with App
# code". The user/password session-token scheme is not implemented. A secret:
# prefer the environment variable.
token = ""                          # ACME_PROXY_IPAM__PHPIPAM__TOKEN
# Column holding the extra names an address may have certified, on the address
# and — for the `device` source — on the device. phpIPAM prefixes custom
# columns with `custom_`, so the default carries that prefix. It is a text
# column, so several names are written comma-separated.
custom_field = "custom_acme_domains"  # ACME_PROXY_IPAM__PHPIPAM__CUSTOM_FIELD
# Extra CA certificates (PEM) to trust on top of the public roots.
# Ignored when insecure_skip_verify is on.
ca_cert_path = ""                   # ACME_PROXY_IPAM__PHPIPAM__CA_CERT_PATH
# Skip verification of phpIPAM's TLS certificate entirely — the counterpart of
# [ipam.netbox].insecure_skip_verify, and warned about at startup in the same
# way for as long as it is set.
insecure_skip_verify = false        # ACME_PROXY_IPAM__PHPIPAM__INSECURE_SKIP_VERIFY

[ipam.custom]
# Only read when backend = "custom" (above). The inventory is an operator
# script, so this section has no url, no credential and no `sources`: the
# script decides for itself where its answer comes from — a CMDB, a hosts
# file, an LDAP tree, a vendor API this server carries no client for.
#
# The script is told the address twice, so neither a one-line shell script nor
# a Python one has to reach for the channel it finds awkward:
#
#   ACME_IPAM_HOOK=names_for        the only hook there is
#   ACME_IPAM_CLIENT_IP=<address>   the resolved, canonicalized client address
#   stdin                           {"hook":"names_for","client_ip":"..."}
#
# It answers with stdout plus an exit code:
#
#   exit 0    stdout is the permitted names, ONE PER LINE. Blank lines are
#             ignored, and each name is lowercased and stripped of a trailing
#             dot, so the script may print whatever form its inventory holds.
#             Empty stdout means "recorded, and entitled to nothing".
#   exit 3    RESERVED: this inventory holds no record of that address at all.
#             A different refusal from the one above, worded its own way, so
#             an operator reading a 403 can tell the two apart. stdout is
#             ignored.
#   anything  the script failed, which is the SERVER failing to decide: a
#   else      retryable 500, never a denial. Same for a script that cannot be
#             spawned or that runs past timeout_ms. The first non-empty line of
#             stdout (else stderr) is the reason logged.
#
# An empty script_path is a startup error, the same as [signer.custom] and
# [filter.check.<name>] of type "custom".
#
# NOTE `acme-proxy filter explain` really runs the policy, so it executes this
# script — as it does the `custom` filter's.
script_path = ""                    # ACME_PROXY_IPAM__CUSTOM__SCRIPT_PATH
# Fixed arguments passed before the script is told anything about the request.
# One script can serve several deployments by branching on them.
args = []                           # ACME_PROXY_IPAM__CUSTOM__ARGS
#
# There is deliberately NO timeout_ms here: ipam.timeout_ms above is the budget
# the whole lookup runs under, and it is what kills the child at the deadline.

[eab]   # [per-profile] — a [profiles.<name>] block may override these
# Require External Account Binding (RFC 8555 §7.3.4) on every newAccount that
# would create an account. A request missing it gets 400
# externalAccountRequired; one that is malformed, unknown, revoked, or fails to
# verify gets 400/401 accordingly. onlyReturnExisting lookups are exempt, since
# they never create an account.
#
# OFF by default, like essentially every public ACME CA (Let's Encrypt itself
# does not require it): this is a policy choice, not a "less secure than it
# looks" default, so — unlike an empty filter.rules or challenge.bypass — it
# earns no startup warning when left off.
#
# Keys are managed with the `eab` admin subcommand (create/list/show/revoke),
# never through this file: a credential must be revocable without a restart.
enabled = false                     # ACME_PROXY_EAB__ENABLED

[meta]   # [per-profile] — a [profiles.<name>] block may override these
# The optional `meta` members of the directory object (RFC 8555 §7.1.1). All
# empty by default; an empty one is omitted from the directory rather than
# advertised blank. Like every other profile section, these can be overridden
# per profile ([profiles.<name>.meta]) — two endpoints on one process can have
# different terms.

# A URL identifying this endpoint's current terms of service (§7.1.1).
#
# Not merely cosmetic: setting it turns on §7.3.3's agreement requirement, so
# newAccount then refuses any request that does not carry
# `termsOfServiceAgreed: true`, answering 403 userActionRequired with a
# `Link: <url>;rel="terms-of-service"` header. Left empty (the default) no
# agreement is asked for, and the account object reflects no
# `termsOfServiceAgreed` member at all — an account created here never agreed
# to anything, and saying `false` would misrepresent that.
terms_of_service = ""               # ACME_PROXY_META__TERMS_OF_SERVICE

# An HTTP(S) URL with more information about this ACME server, for a human who
# found the endpoint and wants to know whose it is.
website = ""                        # ACME_PROXY_META__WEBSITE

# The hostnames this CA recognizes in CAA records (§7.1.1). Advertised only —
# this server performs no CAA checking of its own, since it is built to serve
# private networks where public CAA policy is not the trust anchor.
caa_identities = []                 # ACME_PROXY_META__CAA_IDENTITIES

[notify]   # [per-profile] — a [profiles.<name>] block may override these
# Operator notifications on lifecycle events: a profile mounting at startup,
# account creation/deactivation, certificate issuance/revocation, and a
# challenge validation failure. Dispatch is fire-and-forget — a notify
# backend can never fail or delay the ACME response that triggered it; any
# delivery failure is only logged.
#
# Which backends are active: "email", "webhook", "custom". Empty (default)
# means no notifications are sent at all.
enabled = []                        # ACME_PROXY_NOTIFY__ENABLED
# Which of [notify.webhook]'s entries to POST to, when "webhook" is listed
# above — same shape as custom_enabled below: "webhook" enabled with this
# empty, or an entry named here that isn't defined below, is a startup error.
webhook_enabled = []                # ACME_PROXY_NOTIFY__WEBHOOK_ENABLED
# Which of [notify.custom]'s entries to run, when "custom" is listed above —
# same shape as webhook_enabled above: "custom" enabled with
# this empty, or an entry named here that isn't defined below, is a startup
# error.
custom_enabled = []                 # ACME_PROXY_NOTIFY__CUSTOM_ENABLED
# Filesystem directory to look for template overrides in, checked per
# template file before falling back to the compiled-in default — so an
# operator can override just one message (e.g.
# "email/certificate_issued.body.j2") and leave every other one at its
# default. Empty (default) means compiled-in defaults only.
template_dir = ""                   # ACME_PROXY_NOTIFY__TEMPLATE_DIR

[notify.email]
# SMTP delivery. Required once "email" is in notify.enabled: smtp_host, from
# and to.
smtp_host = ""                      # ACME_PROXY_NOTIFY__EMAIL__SMTP_HOST
smtp_port = 587                     # ACME_PROXY_NOTIFY__EMAIL__SMTP_PORT
smtp_username = ""                  # ACME_PROXY_NOTIFY__EMAIL__SMTP_USERNAME
# SENSITIVE — prefer the environment variable to a file on disk, like every
# other secret in this file (the RFC 2136 TSIG key, the NetBox token).
smtp_password = ""                  # ACME_PROXY_NOTIFY__EMAIL__SMTP_PASSWORD
# "starttls" (default), "tls" (implicit TLS) or "none".
smtp_security = "starttls"          # ACME_PROXY_NOTIFY__EMAIL__SMTP_SECURITY
from = ""                           # ACME_PROXY_NOTIFY__EMAIL__FROM
to = []                             # ACME_PROXY_NOTIFY__EMAIL__TO
# Which lifecycle events this backend reacts to. Defaults to all of them,
# listed explicitly rather than relying on "empty means all" — every other
# list field in this file treats empty as OFF, so reusing that convention
# here would silently mean "no events" the moment this is set to [].
# "certificates_expiring" is the periodic digest below, which sends nothing
# until notify.expiry.lead_days is set, so leaving it listed costs nothing.
# "admin_sign_in" / "admin_credential_changed" fire only on the process-wide
# [admin.notify] dispatcher (see below), never on a profile's, so listing them
# on a per-profile backend costs nothing either.
events = ["profile_mounted", "account_created", "account_deactivated",
          "certificate_issued", "certificate_revoked", "challenge_failed",
          "certificates_expiring", "admin_sign_in", "admin_credential_changed"]
                                     # ACME_PROXY_NOTIFY__EMAIL__EVENTS
timeout_ms = 5000                   # ACME_PROXY_NOTIFY__EMAIL__TIMEOUT_MS

[notify.expiry]
# The periodic digest of certificates approaching their expiry: ONE message
# per profile per interval_days, listing what lapses inside lead_days.
#
# Deliberately not one message per certificate. A renewal is a new order, so
# the certificate it replaced still expires on schedule — a per-certificate
# reminder therefore fires for every certificate this CA has ever issued, on
# its way out, in exactly the deployments where the automation is working.
# The digest lists the already-replaced ones too, annotated, so the entries
# nobody has renewed are the ones that stand out.
#
# 0 (the default) is OFF — the job is never scheduled at all, the same shape
# as audit.retention_days and jobs.retention_days.
lead_days = 0                       # ACME_PROXY_NOTIFY__EXPIRY__LEAD_DAYS
# How often the digest is sent. There is deliberately no per-certificate rate
# limit beside it: the digest is the rate limit.
interval_days = 7                   # ACME_PROXY_NOTIFY__EXPIRY__INTERVAL_DAYS
# The most certificates one message lists. The number that matched is carried
# whole regardless, so a truncated digest still says how many it did not name.
max_entries = 50                    # ACME_PROXY_NOTIFY__EXPIRY__MAX_ENTRIES

# notify.webhook holds NAMED HTTP targets: one request per event, with the
# URL, the method, the headers and the body all stated here. That is the whole
# point — Slack, Mattermost, Microsoft Teams, Telegram and Matrix differ in
# those four values and nothing else, so a chat provider is configuration
# rather than a backend of its own. Names follow ^[a-z0-9-]+$, the
# [notify.custom] rule and for the same ACME_PROXY_NOTIFY__WEBHOOK__<NAME>__...
# reason.
#
# The body is a MiniJinja template with `message` (the rendered
# "webhook/<event>.j2", overridable through notify.template_dir), `hook` and
# every field of the event in scope. Note the `tojson` filter in the default
# below: a .j2 template has auto-escaping OFF, so a message holding a quote or
# a newline needs it to stay valid JSON.
#
# The URL and the headers routinely carry the credential (a Slack hook id, a
# Telegram bot token, a Matrix access token), so nothing is ever logged of
# either beyond the host and the header names. SENSITIVE, like every other
# secret here: prefer the environment variable.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. webhook_enabled = [] and no targets configured). See
# doc/src/notifications/webhook.md for the recipe of each provider:
#
# [notify.webhook.slack]
# url = "https://hooks.slack.com/services/T00000000/B00000000/XXXX"
#                                     # ACME_PROXY_NOTIFY__WEBHOOK__SLACK__URL
# method = "POST"                     # POST (default), PUT or PATCH
# body = '{"text": {{ message | tojson }}}'
# events = ["certificate_issued", "certificate_revoked"]
# timeout_ms = 5000
#
# [notify.webhook.matrix.headers]     # ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__HEADERS__AUTHORIZATION
# Authorization = "Bearer syt_xxxxx"

# notify.custom holds NAMED external scripts, the notify-side counterpart of a
# `custom` filter check and signer.custom — the same "shell out to a script"
# contract, and a restriction on names (^[a-z0-9-]+$, for the
# ACME_PROXY_NOTIFY__CUSTOM__<NAME>__... reason).
# This is what lets an operator wire up a channel that is not an HTTP request
# at all (a paging daemon, a local socket, `wall`) — for anything that IS one,
# [notify.webhook] above is the answer and needs no script.
#
# Context via environment variables:
#   ACME_NOTIFY_HOOK          - event kind, e.g. "certificate_issued"
#   ACME_NOTIFY_PROFILE       - the profile name
#   ACME_NOTIFY_CLIENT_IP     - client IP (or empty — always empty for the
#                               asynchronous relay completion path,
#                               which has no request in scope at all)
#   ACME_NOTIFY_ACCOUNT_ID    - account id (or empty, not every event has one)
#   ACME_NOTIFY_ORDER_ID      - order id (or empty)
#   ACME_NOTIFY_CERT_SERIAL   - certificate serial (or empty)
#   ACME_NOTIFY_IDENTIFIERS   - comma-separated identifier values (or empty)
#
# A JSON payload with the full event data, tagged "hook", is always piped to
# stdin. Unlike signer.custom's "issue" hook, there is no reserved exit code
# for a distinct failure kind: this is fire-and-forget, not request-blocking,
# so any non-zero exit is uniformly "delivery failed" (the first non-empty
# line of stdout, or stderr, becomes the logged detail).
#
# The script does NOT inherit the server's environment, for the same reason a
# `custom` filter check and [signer.custom] don't: this process's own
# environment may hold secrets (e.g. notify.email.smtp_password), and a notify
# script has no business reading them.
#
# One process is spawned per enabled script per event. A script still
# running when timeout_ms expires is killed, not left behind.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. custom_enabled = [] and no scripts configured):
#
# [notify.custom.slack]
# script_path = "/etc/acme-proxy/notify/slack.sh"
# timeout_ms = 5000
# args = []
# events = ["certificate_issued", "certificate_revoked"]

[dns]
# The resolver every DNS lookup this server makes goes through: the dns-01 TXT
# query, the connect target http-01/tls-alpn-01 resolve before reaching out,
# and the PTR/forward lookups filter.reverse_dns makes. One knob rather than
# one per consumer, because they all answer the same question — which
# nameserver this process trusts.
#
# Unset by default: every lookup uses the system configuration
# (/etc/resolv.conf), exactly as before this setting existed. Set it to point
# every lookup at a specific nameserver instead — a split-horizon test network
# being the main reason to.
# resolver = "10.60.0.2:53"           # ACME_PROXY_DNS__RESOLVER

[proxy]
# The forward proxy every outbound HTTP client dials through: the upstream CA
# the `relay` signer talks to, the IPAM inventory, the notification webhooks,
# and the http-01/tls-alpn-01 challenge validators (the latter through a
# CONNECT tunnel, since it is TLS rather than HTTP).
#
# Not everything outbound: SMTP (notify.email) and the RFC 2136 DNS updates
# signer.relay.dns01 makes are not HTTP and keep dialling directly. An estate
# whose egress is proxy-only needs a separate route for those two.
#
# Every key is empty by default, which means no proxy at all. Each falls back
# to its conventional environment variable when left empty:
#
#   http_url   ->  $http_proxy
#   https_url  ->  $https_proxy, then $HTTPS_PROXY
#   no_proxy   ->  $no_proxy,    then $NO_PROXY
#
# Uppercase HTTP_PROXY is deliberately *not* read (httpoxy, CVE-2016-5385:
# under CGI a client-supplied `Proxy:` header lands in the environment under
# that name). This server is never a CGI process, but Go's net/http dropped
# the variable for the same reason and matching it costs nothing.
#
# The proxy itself is always reached in the clear: https_url names the proxy
# used *for* https targets, and that proxy is normally still http://host:port.
# An https:// value here is a startup error, as is a socks5:// one.
#
# A proxy that is configured but unreachable is an error, every time — there is
# no fallback to a direct connection, since dialling around a controlled egress
# path exactly when the control fails is the opposite of what it is for.
# http_url  = "http://proxy.example.com:3128"   # ACME_PROXY_PROXY__HTTP_URL
# https_url = "http://proxy.example.com:3128"   # ACME_PROXY_PROXY__HTTPS_URL
#
# Credentials go in the URL and are sent as `Proxy-Authorization: Basic`:
# http://user:password@proxy.example.com:3128 (percent-encode anything odd —
# DOMAIN%5Cuser is decoded before the header is built).
#
# Targets that bypass the proxy. An entry is `*` (everything), a domain (which
# also matches everything under it), a `.domain` (the same thing), an address,
# or a CIDR block. A network entry is compared only against a target that is
# already an address literal — a hostname is never resolved to test one, which
# would be a DNS lookup per request and a race with the connect that follows.
# Loopback and `localhost` bypass unconditionally and need no entry.
# no_proxy = ["10.0.0.0/8", ".internal.example"]  # ACME_PROXY_PROXY__NO_PROXY

# ---------------------------------------------------------------------------
# Profiles: the ACME endpoints this server actually serves.
# ---------------------------------------------------------------------------
#
# Everything above is the *base*. A profile is one ACME endpoint built from it:
# it inherits every [signer] / [filter] / [ipam] / [challenge] / [eab] /
# [order] / [meta] / [notify] value above, key by key, and overrides only what
# it states. So the
# empty table below is a complete endpoint, identical to the configuration
# above.
#
# At least one enabled profile is required — without one the server has nothing
# to serve, and it says so at startup rather than answering 404 to everything.
#
# Each profile is mounted at /profile/<name>, derived from the name and never
# configured: that namespace is reserved, so an endpoint can never collide with
# a server route (/health) present or future. The name is therefore public API
# — renaming it invalidates every account and order URL its clients hold. Use
# lowercase letters, digits and `-`.
#
# From the environment: ACME_PROXY_PROFILES__<NAME>__<SECTION>__<KEY>, e.g.
# `ACME_PROXY_PROFILES__LE__SIGNER__BACKEND=relay`. A profile that
# overrides nothing still needs one variable to exist at all — set
# `ACME_PROXY_PROFILES__DEFAULT__ENABLED=true`.

[profiles.default]
# Mount this profile. Default true; set it to false to park an endpoint without
# deleting its configuration.
enabled = true                      # ACME_PROXY_PROFILES__DEFAULT__ENABLED

# A second endpoint, relaying to a real upstream CA while keeping the filters
# and challenge settings from the base above. Commented out because the example
# must match the defaults it documents; uncomment and adapt.
#
# [profiles.le]
# signer.backend = "relay"
# signer.relay.directory_url = "https://acme-v02.api.letsencrypt.org/directory"
# signer.relay.account_key_path = "le_upstream.key"
# challenge.bypass = false